Merge loop changes staged on Fig to Aurora
authorRandell Jesup <rjesup@jesup.org>
Sun, 05 Oct 2014 02:35:08 -0400
changeset 218104 693efca0fbe7caf33b2898884662d7fa2d01eefa
parent 218024 285ff4f57e70b3f081620f9f2b7b164c1887042c (current diff)
parent 218103 bc811bbf346d223b442fa88d0e98abad4506c692 (diff)
child 218105 701fcc5fcce98223176cb4129bfbdfcee78fe23e
push idunknown
push userunknown
push dateunknown
milestone34.0a2
Merge loop changes staged on Fig to Aurora
browser/app/profile/firefox.js
browser/components/loop/content/js/desktopRouter.js
browser/components/loop/content/shared/js/router.js
browser/components/loop/standalone/content/l10n/data.ini
browser/components/loop/standalone/content/libs/webl10n-20130617.js
browser/components/loop/test/shared/router_test.js
browser/components/loop/test/xpcshell/head.js
browser/themes/linux/jar.mn
browser/themes/linux/social/chat-icons.png
browser/themes/osx/jar.mn
browser/themes/osx/social/chat-icons.png
browser/themes/osx/social/chat-icons@2x.png
browser/themes/windows/jar.mn
browser/themes/windows/social/chat-icons.png
--- a/.hgignore
+++ b/.hgignore
@@ -70,8 +70,20 @@
 GTAGS
 GRTAGS
 GSYMS
 GPATH
 
 # Unit tests for Loop
 ^browser/components/loop/standalone/content/config\.js$
 ^browser/components/loop/standalone/node_modules/
+
+# Loop web client build/deploy dependencies
+^browser/components/loop/standalone/bower_components
+
+# Loop legal content build/deploy artifacts
+
+# XXX Once a grunt contrib-clean command has been added (bug 1066491), or
+# once legal has centralized their ToS and PP hosting infrastructure,
+# (expected Q4 2014) the legal doc build stuff for Loop can be removed,
+# including the following three lines
+^browser/components/loop/standalone/content/legal/styles/.*\.css$
+^browser/components/loop/standalone/content/legal/terms/en_US\.html$
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1578,30 +1578,32 @@ pref("image.mem.max_decoded_image_kb", 2
 // Enable by default development builds up until early beta
 #ifdef EARLY_BETA_OR_EARLIER
 pref("loop.enabled", true);
 pref("loop.throttled", false);
 #else
 pref("loop.enabled", true);
 pref("loop.throttled", true);
 pref("loop.soft_start_ticket_number", -1);
-pref("loop.soft_start_hostname", "soft-start.loop-dev.stage.mozaws.net");
+pref("loop.soft_start_hostname", "soft-start.loop.services.mozilla.com");
 #endif
 
 pref("loop.server", "https://loop.services.mozilla.com");
 pref("loop.seenToS", "unseen");
 pref("loop.legal.ToS_url", "https://accounts.firefox.com/legal/terms");
 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.websocket", false);
+pref("loop.debug.sdk", false);
 
 // serverURL to be assigned by services team
 pref("services.push.serverURL", "wss://push.services.mozilla.com/");
 
 pref("social.sidebar.unload_timeout_ms", 10000);
 
 pref("dom.identity.enabled", false);
 
--- a/browser/base/content/browser-loop.js
+++ b/browser/base/content/browser-loop.js
@@ -27,16 +27,19 @@ XPCOMUtils.defineLazyModuleGetter(this, 
     openCallPanel: function(event) {
       let callback = iframe => {
         iframe.addEventListener("DOMContentLoaded", function documentDOMLoaded() {
           iframe.removeEventListener("DOMContentLoaded", documentDOMLoaded, true);
           injectLoopAPI(iframe.contentWindow);
         }, true);
       };
 
+      // Used to clear the temporary "login" state from the button.
+      Services.obs.notifyObservers(null, "loop-status-changed", null);
+
       PanelFrame.showPopup(window, event.target, "loop", null,
                            "about:looppanel", null, callback);
     },
 
     /**
      * Triggers the initialization of the loop service.  Called by
      * delayedStartup.
      */
@@ -64,22 +67,34 @@ XPCOMUtils.defineLazyModuleGetter(this, 
       Services.obs.removeObserver(this, "loop-status-changed");
     },
 
     // Implements nsIObserver
     observe: function(subject, topic, data) {
       if (topic != "loop-status-changed") {
         return;
       }
-      this.updateToolbarState();
+      this.updateToolbarState(data);
     },
 
-    updateToolbarState: function() {
+    /**
+     * Updates the toolbar/menu-button state to reflect Loop status.
+     *
+     * @param {string} [aReason] Some states are only shown if
+     *                           a related reason is provided.
+     *
+     *                 aReason="login": Used after a login is completed
+     *                   successfully. This is used so the state can be
+     *                   temporarily shown until the next state change.
+     */
+    updateToolbarState: function(aReason = null) {
       let state = "";
       if (MozLoopService.errors.size) {
         state = "error";
+      } else if (aReason == "login" && MozLoopService.userProfile) {
+        state = "active";
       } else if (MozLoopService.doNotDisturb) {
         state = "disabled";
       }
       this.toolbarButton.node.setAttribute("state", state);
     },
   };
 })();
--- a/browser/base/content/socialchat.xml
+++ b/browser/base/content/socialchat.xml
@@ -132,17 +132,19 @@
         PopupNotifications._reshowNotifications(this.content.popupnotificationanchor,
                                                 this.content);
         ]]></body>
       </method>
 
       <method name="swapDocShells">
         <parameter name="aTarget"/>
         <body><![CDATA[
-          aTarget.setAttribute('label', this.contentDocument.title);
+          aTarget.setAttribute("label", this.contentDocument.title);
+          if (this.getAttribute("dark") == "true")
+            aTarget.setAttribute("dark", "true");
           aTarget.src = this.src;
           aTarget.content.setAttribute("origin", this.content.getAttribute("origin"));
           aTarget.content.popupnotificationanchor.className = this.content.popupnotificationanchor.className;
           this.content.swapDocShells(aTarget.content);
         ]]></body>
       </method>
 
       <method name="onTitlebarClick">
--- a/browser/components/loop/LoopStorage.jsm
+++ b/browser/components/loop/LoopStorage.jsm
@@ -1,16 +1,25 @@
 /* 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.importGlobalProperties(["indexedDB"]);
+// Make it possible to load LoopStorage.jsm in xpcshell tests
+try {
+  Cu.importGlobalProperties(["indexedDB"]);
+} catch (ex) {
+  // don't write this is out in xpcshell, since it's expected there
+  if (typeof window !== 'undefined' && "console" in window) {
+    console.log("Failed to import indexedDB; if this isn't a unit test," +
+                " something is wrong", ex);
+  }
+}
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
   const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
   return new EventEmitter();
 });
 
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -1,16 +1,17 @@
 /* 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://services-common/utils.js");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource:///modules/loop/MozLoopService.jsm");
 Cu.import("resource:///modules/loop/LoopContacts.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "hookWindowCloseForPanelClose",
                                         "resource://gre/modules/MozSocialAPI.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
@@ -18,16 +19,19 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyGetter(this, "appInfo", function() {
   return Cc["@mozilla.org/xre/app-info;1"]
            .getService(Ci.nsIXULAppInfo)
            .QueryInterface(Ci.nsIXULRuntime);
 });
 XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper",
                                          "@mozilla.org/widget/clipboardhelper;1",
                                          "nsIClipboardHelper");
+XPCOMUtils.defineLazyServiceGetter(this, "extProtocolSvc",
+                                         "@mozilla.org/uriloader/external-protocol-service;1",
+                                         "nsIExternalProtocolService");
 this.EXPORTED_SYMBOLS = ["injectLoopAPI"];
 
 /**
  * Trying to clone an Error object into a different container will yield an error.
  * We can work around this by copying the properties we care about onto a regular
  * object.
  *
  * @param {Error}        error        Error object to copy
@@ -37,71 +41,111 @@ const cloneErrorObject = function(error,
   let obj = new targetWindow.Error();
   for (let prop of Object.getOwnPropertyNames(error)) {
     obj[prop] = String(error[prop]);
   }
   return obj;
 };
 
 /**
+ * Makes an object or value available to an unprivileged target window.
+ *
+ * Primitives are returned as they are, while objects are cloned into the
+ * specified target.  Error objects are also handled correctly.
+ *
+ * @param {any}          value        Value or object to copy
+ * @param {nsIDOMWindow} targetWindow The content window to copy to
+ */
+const cloneValueInto = function(value, targetWindow) {
+  if (!value || typeof value != "object") {
+    return value;
+  }
+
+  // Inspect for an error this way, because the Error object is special.
+  if (value.constructor.name == "Error") {
+    return cloneErrorObject(value, targetWindow);
+  }
+
+  return Cu.cloneInto(value, targetWindow);
+};
+
+/**
  * Inject any API containing _only_ function properties into the given window.
  *
  * @param {Object}       api          Object containing functions that need to
  *                                    be exposed to content
  * @param {nsIDOMWindow} targetWindow The content window to attach the API
  */
 const injectObjectAPI = function(api, targetWindow) {
   let injectedAPI = {};
   // Wrap all the methods in `api` to help results passed to callbacks get
   // through the priv => unpriv barrier with `Cu.cloneInto()`.
   Object.keys(api).forEach(func => {
     injectedAPI[func] = function(...params) {
       let callback = params.pop();
       api[func](...params, function(...results) {
-        results = results.map(result => {
-          if (result && typeof result == "object") {
-            // Inspect for an error this way, because the Error object is special.
-            if (result.constructor.name == "Error") {
-              return cloneErrorObject(result.message)
-            }
-            return Cu.cloneInto(result, targetWindow);
-          }
-          return result;
-        });
-        callback(...results);
+        callback(...[cloneValueInto(r, targetWindow) for (r of results)]);
       });
     };
   });
 
   let contentObj = Cu.cloneInto(injectedAPI, targetWindow, {cloneFunctions: true});
   // Since we deny preventExtensions on XrayWrappers, because Xray semantics make
   // it difficult to act like an object has actually been frozen, we try to seal
   // 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) {
   let ringer;
   let ringerStopper;
   let appVersionInfo;
   let contactsAPI;
 
   let api = {
     /**
+     * Gets an object with data that represents the currently
+     * authenticated user's identity.
+     *
+     * @return null if user not logged in; profile object otherwise
+     */
+    userProfile: {
+      enumerable: true,
+      get: function() {
+        if (!MozLoopService.userProfile)
+          return null;
+        let userProfile = Cu.cloneInto({
+          email: MozLoopService.userProfile.email,
+          uid: MozLoopService.userProfile.uid
+        }, targetWindow);
+        return userProfile;
+      }
+    },
+
+    /**
      * Sets and gets the "do not disturb" mode activation flag.
      */
     doNotDisturb: {
       enumerable: true,
       get: function() {
         return MozLoopService.doNotDisturb;
       },
       set: function(aFlag) {
@@ -117,16 +161,48 @@ function injectLoopAPI(targetWindow) {
     locale: {
       enumerable: true,
       get: function() {
         return MozLoopService.locale;
       }
     },
 
     /**
+     * Returns the callData for a specific callDataId
+     *
+     * The data was retrieved from the LoopServer via a GET/calls/<version> request
+     * triggered by an incoming message from the LoopPushServer.
+     *
+     * @param {int} loopCallId
+     * @returns {callData} The callData or undefined if error.
+     */
+    getCallData: {
+      enumerable: true,
+      writable: true,
+      value: function(loopCallId) {
+        return Cu.cloneInto(MozLoopService.getCallData(loopCallId), targetWindow);
+      }
+    },
+
+    /**
+     * Releases the callData for a specific loopCallId
+     *
+     * The result of this call will be a free call session slot.
+     *
+     * @param {int} loopCallId
+     */
+    releaseCallData: {
+      enumerable: true,
+      writable: true,
+      value: function(loopCallId) {
+        MozLoopService.releaseCallData(loopCallId);
+      }
+    },
+
+    /**
      * Returns the contacts API.
      *
      * @returns {Object} The contacts API object
      */
     contacts: {
       enumerable: true,
       get: function() {
         if (contactsAPI) {
@@ -181,21 +257,21 @@ function injectLoopAPI(targetWindow) {
      *                            happened.
      */
     ensureRegistered: {
       enumerable: true,
       writable: true,
       value: function(callback) {
         // We translate from a promise to a callback, as we can't pass promises from
         // Promise.jsm across the priv versus unpriv boundary.
-        return MozLoopService.register().then(() => {
+        MozLoopService.register().then(() => {
           callback(null);
         }, err => {
-          callback(err);
-        });
+          callback(cloneValueInto(err, targetWindow));
+        }).catch(Cu.reportError);
       }
     },
 
     /**
      * Used to note a call url expiry time. If the time is later than the current
      * latest expiry time, then the stored expiry time is increased. For times
      * sooner, this function is a no-op; this ensures we always have the latest
      * expiry time for a url.
@@ -323,43 +399,78 @@ function injectLoopAPI(targetWindow) {
      *    {
      *      code: 401,
      *      errno: 401,
      *      error: "Request failed",
      *      message: "invalid token"
      *    }
      *  - {String} The body of the response.
      *
+     * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for
+     *                                        the request.  This is one of the
+     *                                        LOOP_SESSION_TYPE members
      * @param {String} path The path to make the request to.
      * @param {String} method The request method, e.g. 'POST', 'GET'.
      * @param {Object} payloadObj An object which is converted to JSON and
      *                            transmitted with the request.
      * @param {Function} callback Called when the request completes.
      */
     hawkRequest: {
       enumerable: true,
       writable: true,
-      value: function(path, method, payloadObj, callback) {
+      value: function(sessionType, path, method, payloadObj, callback) {
         // XXX Should really return a DOM promise here.
-        return MozLoopService.hawkRequest(path, method, payloadObj).then((response) => {
+        MozLoopService.hawkRequest(sessionType, path, method, payloadObj).then((response) => {
           callback(null, response.body);
-        }, (error) => {
-          callback(Cu.cloneInto(error, targetWindow));
-        });
+        }, hawkError => {
+          // The hawkError.error property, while usually a string representing
+          // an HTTP response status message, may also incorrectly be a native
+          // error object that will cause the cloning function to fail.
+          callback(Cu.cloneInto({
+            error: (hawkError.error && typeof hawkError.error == "string")
+                   ? hawkError.error : "Unexpected exception",
+            message: hawkError.message,
+            code: hawkError.code,
+            errno: hawkError.errno,
+          }, targetWindow));
+        }).catch(Cu.reportError);
+      }
+    },
+
+    LOOP_SESSION_TYPE: {
+      enumerable: true,
+      get: function() {
+        return Cu.cloneInto(LOOP_SESSION_TYPE, targetWindow);
       }
     },
 
     logInToFxA: {
       enumerable: true,
       writable: true,
       value: function() {
         return MozLoopService.logInToFxA();
       }
     },
 
+    logOutFromFxA: {
+      enumerable: true,
+      writable: true,
+      value: function() {
+        return MozLoopService.logOutFromFxA();
+      }
+    },
+
+    openFxASettings: {
+      enumerable: true,
+      writable: true,
+      value: function() {
+        return MozLoopService.openFxASettings();
+      },
+    },
+
     /**
      * Copies passed string onto the system clipboard.
      *
      * @param {String} str The string to copy
      */
     copyString: {
       enumerable: true,
       writable: true,
@@ -377,43 +488,150 @@ function injectLoopAPI(targetWindow) {
      *   - OS: The operating system the application is running on
      */
     appVersionInfo: {
       enumerable: true,
       get: function() {
         if (!appVersionInfo) {
           let defaults = Services.prefs.getDefaultBranch(null);
 
-          appVersionInfo = Cu.cloneInto({
-            channel: defaults.getCharPref("app.update.channel"),
-            version: appInfo.version,
-            OS: appInfo.OS
-          }, targetWindow);
+          // If the lazy getter explodes, we're probably loaded in xpcshell,
+          // which doesn't have what we need, so log an error.
+          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);
+            }
+          }
         }
         return appVersionInfo;
       }
     },
+
+    /**
+     * Composes an email via the external protocol service.
+     *
+     * @param {String} subject Subject of the email to send
+     * @param {String} body    Body message of the email to send
+     */
+    composeEmail: {
+      enumerable: true,
+      writable: true,
+      value: function(subject, body) {
+        let mailtoURL = "mailto:?subject=" + encodeURIComponent(subject) + "&" +
+                        "body=" + encodeURIComponent(body);
+        extProtocolSvc.loadURI(CommonUtils.makeURI(mailtoURL));
+      }
+    },
+
+    /**
+     * Adds a value to a telemetry histogram.
+     *
+     * @param  {string}  histogramId Name of the telemetry histogram to update.
+     * @param  {integer} value       Value to add to the histogram.
+     */
+    telemetryAdd: {
+      enumerable: true,
+      writable: true,
+      value: function(histogramId, value) {
+        Services.telemetry.getHistogramById(histogramId).add(value);
+      }
+    },
+
+    /**
+     * Returns a new GUID (UUID) in curly braces format.
+     */
+    generateUUID: {
+      enumerable: true,
+      writable: true,
+      value: function() {
+        return MozLoopService.generateUUID();
+      }
+    },
+
+    /**
+     * Compose a URL pointing to the location of an avatar by email address.
+     * At the moment we use the Gravatar service to match email addresses with
+     * avatars. This might change in the future as avatars might come from another
+     * source.
+     *
+     * @param {String} emailAddress Users' email address
+     * @param {Number} size         Size of the avatar image to return in pixels.
+     *                              Optional. Default value: 40.
+     * @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) {
+    if (targetWindow && aSubject != targetWindow)
+      return;
+    Services.obs.removeObserver(onDOMWindowDestroyed, "dom-window-destroyed");
+    Services.obs.removeObserver(onStatusChanged, "loop-status-changed");
   };
 
   let contentObj = Cu.createObjectIn(targetWindow);
   Object.defineProperties(contentObj, api);
   Object.seal(contentObj);
   Cu.makeObjectPropsNormal(contentObj);
+  Services.obs.addObserver(onStatusChanged, "loop-status-changed", false);
+  Services.obs.addObserver(onDOMWindowDestroyed, "dom-window-destroyed", false);
 
-  targetWindow.navigator.wrappedJSObject.__defineGetter__("mozLoop", function() {
-    // We do this in a getter, so that we create these objects
-    // only on demand (this is a potential concern, since
-    // otherwise we might add one per iframe, and keep them
-    // alive for as long as the window is alive).
-    delete targetWindow.navigator.wrappedJSObject.mozLoop;
-    return targetWindow.navigator.wrappedJSObject.mozLoop = contentObj;
-  });
+  if ("navigator" in targetWindow) {
+    targetWindow.navigator.wrappedJSObject.__defineGetter__("mozLoop", function () {
+      // We do this in a getter, so that we create these objects
+      // only on demand (this is a potential concern, since
+      // otherwise we might add one per iframe, and keep them
+      // alive for as long as the window is alive).
+      delete targetWindow.navigator.wrappedJSObject.mozLoop;
+      return targetWindow.navigator.wrappedJSObject.mozLoop = contentObj;
+    });
 
-  // Handle window.close correctly on the panel and chatbox.
-  hookWindowCloseForPanelClose(targetWindow);
+    // Handle window.close correctly on the panel and chatbox.
+    hookWindowCloseForPanelClose(targetWindow);
+  } else {
+    // This isn't a window; but it should be a JS scope; used for testing
+    return targetWindow.mozLoop = contentObj;
+  }
+
 }
 
 function getChromeWindow(contentWin) {
   return contentWin.QueryInterface(Ci.nsIInterfaceRequestor)
                    .getInterface(Ci.nsIWebNavigation)
                    .QueryInterface(Ci.nsIDocShellTreeItem)
                    .rootTreeItem
                    .QueryInterface(Ci.nsIInterfaceRequestor)
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -10,42 +10,51 @@ const { classes: Cc, interfaces: Ci, uti
 // 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.
 const MAX_SOFT_START_TICKET_NUMBER = 16777214;
 
+const LOOP_SESSION_TYPE = {
+  GUEST: 1,
+  FXA: 2,
+};
+
+// See LOG_LEVELS in Console.jsm. Common examples: "All", "Info", "Warn", & "Error".
+const PREF_LOG_LEVEL = "loop.debug.loglevel";
+
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
-
-this.EXPORTED_SYMBOLS = ["MozLoopService"];
+Cu.importGlobalProperties(["URL"]);
 
-XPCOMUtils.defineLazyModuleGetter(this, "console",
-  "resource://gre/modules/devtools/Console.jsm");
+this.EXPORTED_SYMBOLS = ["MozLoopService", "LOOP_SESSION_TYPE"];
 
 XPCOMUtils.defineLazyModuleGetter(this, "injectLoopAPI",
   "resource:///modules/loop/MozLoopAPI.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "convertToRTCStatsReport",
   "resource://gre/modules/media/RTCStatsReport.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Chat", "resource:///modules/Chat.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
                                   "resource://services-common/utils.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "CryptoUtils",
                                   "resource://services-crypto/utils.js");
 
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfileClient",
+                                  "resource://gre/modules/FxAccountsProfileClient.jsm");
+
 XPCOMUtils.defineLazyModuleGetter(this, "HawkClient",
                                   "resource://services-common/hawkclient.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "deriveHawkCredentials",
                                   "resource://services-common/hawkrequest.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
                                   "resource:///modules/loop/MozLoopPushHandler.jsm");
@@ -53,39 +62,205 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
                                    "@mozilla.org/uuid-generator;1",
                                    "nsIUUIDGenerator");
 
 XPCOMUtils.defineLazyServiceGetter(this, "gDNSService",
                                    "@mozilla.org/network/dns-service;1",
                                    "nsIDNSService");
 
+// Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+  let ConsoleAPI = Cu.import("resource://gre/modules/devtools/Console.jsm", {}).ConsoleAPI;
+  let consoleOptions = {
+    maxLogLevel: Services.prefs.getCharPref(PREF_LOG_LEVEL).toLowerCase(),
+    prefix: "Loop",
+  };
+  return new ConsoleAPI(consoleOptions);
+});
 
 // The current deferred for the registration process. This is set if in progress
 // or the registration was successful. This is null if a registration attempt was
 // unsuccessful.
 let gRegisteredDeferred = null;
 let gPushHandler = null;
 let gHawkClient = null;
-let gRegisteredLoopServer = false;
 let gLocalizedStrings =  null;
 let gInitializeTimer = null;
 let gFxAOAuthClientPromise = null;
 let gFxAOAuthClient = null;
 let gFxAOAuthTokenData = null;
+let gFxAOAuthProfile = null;
 let gErrors = new Map();
 
+ /**
+ * Attempts to open a websocket.
+ *
+ * A new websocket interface is used each time. If an onStop callback
+ * was received, calling asyncOpen() on the same interface will
+ * trigger a "alreay open socket" exception even though the channel
+ * is logically closed.
+ */
+function CallProgressSocket(progressUrl, callId, token) {
+  if (!progressUrl || !callId || !token) {
+    throw new Error("missing required arguments");
+  }
+
+  this._progressUrl = progressUrl;
+  this._callId = callId;
+  this._token = token;
+}
+
+CallProgressSocket.prototype = {
+  /**
+   * Open websocket and run hello exchange.
+   * Sends a hello message to the server.
+   *
+   * @param {function} Callback used after a successful handshake
+   *                   over the progressUrl.
+   * @param {function} Callback used if an error is encountered
+   */
+  connect: function(onSuccess, onError) {
+    this._onSuccess = onSuccess;
+    this._onError = onError ||
+      (reason => {log.warn("MozLoopService::callProgessSocket - ", reason);});
+
+    if (!onSuccess) {
+      this._onError("missing onSuccess argument");
+      return;
+    }
+
+    if (Services.io.offline) {
+      this._onError("IO offline");
+      return;
+    }
+
+    let uri = Services.io.newURI(this._progressUrl, null, null);
+
+    // Allow _websocket to be set for testing.
+    this._websocket = this._websocket ||
+      Cc["@mozilla.org/network/protocol;1?name=" + uri.scheme]
+        .createInstance(Ci.nsIWebSocketChannel);
+
+    this._websocket.asyncOpen(uri, this._progressUrl, this, null);
+  },
+
+  /**
+   * Listener method, handles the start of the websocket stream.
+   * Sends a hello message to the server.
+   *
+   * @param {nsISupports} aContext Not used
+   */
+  onStart: function() {
+    let helloMsg = {
+      messageType: "hello",
+      callId: this._callId,
+      auth: this._token,
+    };
+    try { // in case websocket has closed before this handler is run
+      this._websocket.sendMsg(JSON.stringify(helloMsg));
+    }
+    catch (error) {
+      this._onError(error);
+    }
+  },
+
+  /**
+   * Listener method, called when the websocket is closed.
+   *
+   * @param {nsISupports} aContext Not used
+   * @param {nsresult} aStatusCode Reason for stopping (NS_OK = successful)
+   */
+  onStop: function(aContext, aStatusCode) {
+    if (!this._handshakeComplete) {
+      this._onError("[" + aStatusCode + "]");
+    }
+  },
+
+  /**
+   * Listener method, called when the websocket is closed by the server.
+   * If there are errors, onStop may be called without ever calling this
+   * method.
+   *
+   * @param {nsISupports} aContext Not used
+   * @param {integer} aCode the websocket closing handshake close code
+   * @param {String} aReason the websocket closing handshake close reason
+   */
+  onServerClose: function(aContext, aCode, aReason) {
+    if (!this._handshakeComplete) {
+      this._onError("[" + aCode + "]" + aReason);
+    }
+  },
+
+  /**
+   * Listener method, called when the websocket receives a message.
+   *
+   * @param {nsISupports} aContext Not used
+   * @param {String} aMsg The message data
+   */
+  onMessageAvailable: function(aContext, aMsg) {
+    let msg = {};
+    try {
+      msg = JSON.parse(aMsg);
+    }
+    catch (error) {
+      log.error("MozLoopService: error parsing progress message - ", error);
+      return;
+    }
+
+    if (msg.messageType && msg.messageType === 'hello') {
+      this._handshakeComplete = true;
+      this._onSuccess();
+    }
+  },
+
+
+  /**
+   * Create a JSON message payload and send on websocket.
+   *
+   * @param {Object} aMsg Message to send.
+   */
+  _send: function(aMsg) {
+    if (!this._handshakeComplete) {
+      log.warn("MozLoopService::_send error - handshake not complete");
+      return;
+    }
+
+    try {
+      this._websocket.sendMsg(JSON.stringify(aMsg));
+    }
+    catch (error) {
+      this._onError(error);
+    }
+  },
+
+  /**
+   * Notifies the server that the user has declined the call
+   * with a reason of busy.
+   */
+  sendBusy: function() {
+    this._send({
+      messageType: "action",
+      event: "terminate",
+      reason: "busy"
+    });
+  },
+};
+
 /**
  * Internal helper methods and state
  *
  * The registration is a two-part process. First we need to connect to
  * and register with the push server. Then we need to take the result of that
  * and register with the Loop server.
  */
 let MozLoopServiceInternal = {
+  callsData: {inUse: false},
+  _mocks: {webSocket: undefined},
+
   // The uri of the Loop server.
   get loopServerUri() Services.prefs.getCharPref("loop.server"),
 
   /**
    * The initial delay for push registration. This ensures we don't start
    * kicking off straight after browser startup, just a few seconds later.
    */
   get initialRegistrationDelayMilliseconds() {
@@ -144,18 +319,19 @@ let MozLoopServiceInternal = {
    *
    * @param {Boolean} aFlag
    */
   set doNotDisturb(aFlag) {
     Services.prefs.setBoolPref("loop.do_not_disturb", Boolean(aFlag));
     this.notifyStatusChanged();
   },
 
-  notifyStatusChanged: function() {
-    Services.obs.notifyObservers(null, "loop-status-changed", null);
+  notifyStatusChanged: function(aReason = null) {
+    log.debug("notifyStatusChanged with reason:", aReason);
+    Services.obs.notifyObservers(null, "loop-status-changed", aReason);
   },
 
   /**
    * @param {String} errorType a key to identify the type of error. Only one
    *                           error of a type will be saved at a time.
    * @param {Object} error     an object describing the error in the format from Hawk errors
    */
   setError: function(errorType, error) {
@@ -176,17 +352,19 @@ let MozLoopServiceInternal = {
    * Starts registration of Loop with the push server, and then will register
    * 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.
    */
-  promiseRegisteredWithServers: function(mockPushHandler) {
+  promiseRegisteredWithServers: function(mockPushHandler, mockWebSocket) {
+    this._mocks.webSocket = mockWebSocket;
+
     if (gRegisteredDeferred) {
       return gRegisteredDeferred.promise;
     }
 
     gRegisteredDeferred = Promise.defer();
     // We grab the promise early in case .initialize or its results sets
     // it back to null on error.
     let result = gRegisteredDeferred.promise;
@@ -197,154 +375,318 @@ let MozLoopServiceInternal = {
       this.onHandleNotification.bind(this));
 
     return result;
   },
 
   /**
    * Performs a hawk based request to the loop server.
    *
+   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
+   *                                        This is one of the LOOP_SESSION_TYPE members.
    * @param {String} path The path to make the request to.
    * @param {String} method The request method, e.g. 'POST', 'GET'.
    * @param {Object} payloadObj An object which is converted to JSON and
    *                            transmitted with the request.
    * @returns {Promise}
    *        Returns a promise that resolves to the response of the API call,
    *        or is rejected with an error.  If the server response can be parsed
    *        as JSON and contains an 'error' property, the promise will be
    *        rejected with this JSON-parsed response.
    */
-  hawkRequest: function(path, method, payloadObj) {
+  hawkRequest: function(sessionType, path, method, payloadObj) {
     if (!gHawkClient) {
       gHawkClient = new HawkClient(this.loopServerUri);
     }
 
     let sessionToken;
     try {
-      sessionToken = Services.prefs.getCharPref("loop.hawk-session-token");
+      sessionToken = Services.prefs.getCharPref(this.getSessionTokenPrefName(sessionType));
     } catch (x) {
       // It is ok for this not to exist, we'll default to sending no-creds
     }
 
     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).catch(error => {
-      console.error("Loop hawkRequest error:", error);
-      throw error;
-    });
+    return gHawkClient.request(path, method, credentials, payloadObj);
+  },
+
+  /**
+   * Generic hawkRequest onError handler for the hawkRequest promise.
+   *
+   * @param {Object} error - error reporting object
+   *
+   */
+
+  _hawkRequestError: function(error) {
+    log.error("Loop hawkRequest error:", error);
+    throw error;
+  },
+
+  getSessionTokenPrefName: function(sessionType) {
+    let suffix;
+    switch (sessionType) {
+      case LOOP_SESSION_TYPE.GUEST:
+        suffix = "";
+        break;
+      case LOOP_SESSION_TYPE.FXA:
+        suffix = ".fxa";
+        break;
+      default:
+        throw new Error("Unknown LOOP_SESSION_TYPE");
+        break;
+    }
+    return "loop.hawk-session-token" + suffix;
   },
 
   /**
    * Used to store a session token from a request if it exists in the headers.
    *
+   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
+   *                                        One of the LOOP_SESSION_TYPE members.
    * @param {Object} headers The request headers, which may include a
    *                         "hawk-session-token" to be saved.
    * @return true on success or no token, false on failure.
    */
-  storeSessionToken: function(headers) {
+  storeSessionToken: function(sessionType, headers) {
     let sessionToken = headers["hawk-session-token"];
     if (sessionToken) {
       // XXX should do more validation here
       if (sessionToken.length === 64) {
-        Services.prefs.setCharPref("loop.hawk-session-token", sessionToken);
+        Services.prefs.setCharPref(this.getSessionTokenPrefName(sessionType), sessionToken);
+        log.debug("Stored a hawk session token for sessionType", sessionType);
       } else {
         // XXX Bubble the precise details up to the UI somehow (bug 1013248).
-        console.warn("Loop server sent an invalid session token");
+        log.warn("Loop server sent an invalid session token");
         gRegisteredDeferred.reject("session-token-wrong-size");
         gRegisteredDeferred = null;
         return false;
       }
     }
     return true;
   },
 
+
+  /**
+   * Clear the loop session token so we don't use it for Hawk Requests anymore.
+   *
+   * This should normally be used after unregistering with the server so it can
+   * clean up session state first.
+   *
+   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
+   *                                        One of the LOOP_SESSION_TYPE members.
+   */
+  clearSessionToken: function(sessionType) {
+    Services.prefs.clearUserPref(this.getSessionTokenPrefName(sessionType));
+    log.debug("Cleared hawk session token for sessionType", sessionType);
+  },
+
   /**
    * Callback from MozLoopPushHandler - The push server has been registered
    * and has given us a push url.
    *
    * @param {String} pushUrl The push url given by the push server.
    */
   onPushRegistered: function(err, pushUrl) {
     if (err) {
       gRegisteredDeferred.reject(err);
       gRegisteredDeferred = null;
       return;
     }
 
-    this.registerWithLoopServer(pushUrl);
+    this.registerWithLoopServer(LOOP_SESSION_TYPE.GUEST, pushUrl).then(() => {
+      // storeSessionToken could have rejected and nulled the promise if the token was malformed.
+      if (!gRegisteredDeferred) {
+        return;
+      }
+      gRegisteredDeferred.resolve();
+      // No need to clear the promise here, everything was good, so we don't need
+      // to re-register.
+    }, (error) => {
+      log.error("Failed to register with Loop server: ", error);
+      gRegisteredDeferred.reject(error.errno);
+      gRegisteredDeferred = null;
+    });
   },
 
   /**
-   * Registers with the Loop server.
+   * Registers with the Loop server either as a guest or a FxA user.
    *
+   * @param {LOOP_SESSION_TYPE} sessionType The type of session e.g. guest or FxA
    * @param {String} pushUrl The push url given by the push server.
-   * @param {Boolean} noRetry Optional, don't retry if authentication fails.
+   * @param {Boolean} [retry=true] Whether to retry if authentication fails.
+   * @return {Promise}
    */
-  registerWithLoopServer: function(pushUrl, noRetry) {
-    this.hawkRequest("/registration", "POST", { simplePushURL: pushUrl})
+  registerWithLoopServer: function(sessionType, pushUrl, retry = true) {
+    return this.hawkRequest(sessionType, "/registration", "POST", { simplePushURL: pushUrl})
       .then((response) => {
         // If this failed we got an invalid token. storeSessionToken rejects
         // the gRegisteredDeferred promise for us, so here we just need to
         // early return.
-        if (!this.storeSessionToken(response.headers))
+        if (!this.storeSessionToken(sessionType, response.headers))
           return;
 
+        log.debug("Successfully registered with server for sessionType", sessionType);
         this.clearError("registration");
-        gRegisteredDeferred.resolve();
-        // No need to clear the promise here, everything was good, so we don't need
-        // to re-register.
       }, (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.");
           }
 
           // Authorization failed, invalid token, we need to try again with a new token.
-          Services.prefs.clearUserPref("loop.hawk-session-token");
-          this.registerWithLoopServer(pushUrl, true);
-          return;
+          this.clearSessionToken(sessionType);
+          if (retry) {
+            return this.registerWithLoopServer(sessionType, pushUrl, false);
+          }
         }
 
         // XXX Bubble the precise details up to the UI somehow (bug 1013248).
-        Cu.reportError("Failed to register with the loop server. error: " + error);
+        log.error("Failed to register with the loop server. Error: ", error);
         this.setError("registration", error);
-        gRegisteredDeferred.reject(error.errno);
-        gRegisteredDeferred = null;
+        throw error;
       }
     );
   },
 
   /**
+   * Unregisters from the Loop server either as a guest or a FxA user.
+   *
+   * This is normally only wanted for FxA users as we normally want to keep the
+   * 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 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) {
+          // 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;
+      });
+  },
+
+  /**
    * Callback from MozLoopPushHandler - A push notification has been received from
    * the server.
    *
    * @param {String} version The version information from the server.
    */
   onHandleNotification: function(version) {
     if (this.doNotDisturb) {
       return;
     }
 
     // We set this here as it is assumed that once the user receives an incoming
     // call, they'll have had enough time to see the terms of service. See
     // bug 1046039 for background.
     Services.prefs.setCharPref("loop.seenToS", "seen");
 
-    this.openChatWindow(null,
-                        this.localizedStrings["incoming_call_title2"].textContent,
-                        "about:loopconversation#incoming/" + version);
+    // Request the information on the new call(s) associated with this version.
+    // The registered FxA session is checked first, then the anonymous session.
+    // Make the call to get the GUEST session regardless of whether the FXA
+    // request fails.
+
+    this._getCalls(LOOP_SESSION_TYPE.FXA, version).catch(() => {});
+    this._getCalls(LOOP_SESSION_TYPE.GUEST, version).catch(
+      error => {this._hawkRequestError(error);});
+  },
+
+  /**
+   * Make a hawkRequest to GET/calls?=version for this session type.
+   *
+   * @param {LOOP_SESSION_TYPE} sessionType - type of hawk token used
+   *        for the GET operation.
+   * @param {Object} version - LoopPushService notification version
+   *
+   * @returns {Promise}
+   *
+   */
+
+  _getCalls: function(sessionType, version) {
+    return this.hawkRequest(sessionType, "/calls?version=" + version, "GET").then(
+      response => {this._processCalls(response, sessionType);}
+    );
+  },
+
+  /**
+   * Process the calls array returned from a GET/calls?version request.
+   * Only one active call is permitted at this time.
+   *
+   * @param {Object} response - response payload from GET
+   *
+   * @param {LOOP_SESSION_TYPE} sessionType - type of hawk token used
+   *        for the GET operation.
+   *
+   */
+
+  _processCalls: function(response, sessionType) {
+    try {
+      let respData = JSON.parse(response.body);
+      if (respData.calls && Array.isArray(respData.calls)) {
+        respData.calls.forEach((callData) => {
+          if (!this.callsData.inUse) {
+            this.callsData.inUse = true;
+            callData.sessionType = sessionType;
+            this.callsData.data = callData;
+            this.openChatWindow(
+              null,
+              this.localizedStrings["incoming_call_title2"].textContent,
+              "about:loopconversation#incoming/" + callData.callId);
+          } else {
+            this._returnBusy(callData);
+          }
+        });
+      } else {
+        log.warn("Error: missing calls[] in response");
+      }
+    } catch (err) {
+      log.warn("Error parsing calls info", err);
+    }
+  },
+
+   /**
+   * Open call progress websocket and terminate with a reason of busy
+   * the server.
+   *
+   * @param {callData} Must contain the progressURL, callId and websocketToken
+   *                   returned by the LoopService.
+   */
+  _returnBusy: function(callData) {
+    let callProgress = new CallProgressSocket(
+      callData.progressURL,
+      callData.callId,
+      callData.websocketToken);
+    callProgress._websocket = this._mocks.webSocket;
+    // This instance of CallProgressSocket should stay alive until the underlying
+    // websocket is closed since it is passed to the websocket as the nsIWebSocketListener.
+    callProgress.connect(() => {callProgress.sendBusy();});
   },
 
   /**
    * A getter to obtain and store the strings for loop. This is structured
    * for use by l10n.js.
    *
    * @returns {Object} a map of element ids with attributes to set.
    */
@@ -427,17 +769,17 @@ let MozLoopServiceInternal = {
           }
         };
 
         // Send job to worker to do log sanitation, transcoding and saving to
         // disk for pickup by telemetry on next startup, which then uploads it.
 
         let worker = new ChromeWorker("MozLoopWorker.js");
         worker.onmessage = function(e) {
-          console.log(e.data.ok ?
+          log.info(e.data.ok ?
             "Successfully staged loop report for telemetry upload." :
             ("Failed to stage loop report. Error: " + e.data.fail));
         }
         worker.postMessage(job);
       });
     }, pc.id);
   },
 
@@ -459,16 +801,18 @@ let MozLoopServiceInternal = {
       // in about:blank and then get lost.
       // Sadly we can't use chatbox.promiseChatLoaded() as promise chaining
       // involves event loop spins, which means it might be too late.
       // Have we already done it?
       if (chatbox.contentWindow.navigator.mozLoop) {
         return;
       }
 
+      chatbox.setAttribute("dark", true);
+
       chatbox.addEventListener("DOMContentLoaded", function loaded(event) {
         if (event.target != chatbox.contentDocument) {
           return;
         }
         chatbox.removeEventListener("DOMContentLoaded", loaded, true);
 
         let window = chatbox.contentWindow;
         injectLoopAPI(window);
@@ -502,19 +846,29 @@ let MozLoopServiceInternal = {
   },
 
   /**
    * Fetch Firefox Accounts (FxA) OAuth parameters from the Loop Server.
    *
    * @return {Promise} resolved with the body of the hawk request for OAuth parameters.
    */
   promiseFxAOAuthParameters: function() {
-    return this.hawkRequest("/fxa-oauth/params", "POST").then(response => {
+    const SESSION_TYPE = LOOP_SESSION_TYPE.FXA;
+    return this.hawkRequest(SESSION_TYPE, "/fxa-oauth/params", "POST").then(response => {
+      if (!this.storeSessionToken(SESSION_TYPE, response.headers)) {
+        throw new Error("Invalid FxA hawk token returned");
+      }
+      let prefType = Services.prefs.getPrefType(this.getSessionTokenPrefName(SESSION_TYPE));
+      if (prefType == Services.prefs.PREF_INVALID) {
+        throw new Error("No FxA hawk token returned and we don't have one saved");
+      }
+
       return JSON.parse(response.body);
-    });
+    },
+    error => {this._hawkRequestError(error);});
   },
 
   /**
    * Get the OAuth client constructed with Loop OAauth parameters.
    *
    * @return {Promise}
    */
   promiseFxAOAuthClient: Task.async(function* () {
@@ -553,17 +907,17 @@ let MozLoopServiceInternal = {
   promiseFxAOAuthAuthorization: function() {
     let deferred = Promise.defer();
     this.promiseFxAOAuthClient().then(
       client => {
         client.onComplete = this._fxAOAuthComplete.bind(this, deferred);
         client.launchWebFlow();
       },
       error => {
-        console.error(error);
+        log.error(error);
         deferred.reject(error);
       }
     );
     return deferred.promise;
   },
 
   /**
    * Get the OAuth token using the OAuth code and state.
@@ -580,19 +934,20 @@ let MozLoopServiceInternal = {
     if (!code || !state) {
       throw new Error("promiseFxAOAuthToken: code and state are required.");
     }
 
     let payload = {
       code: code,
       state: state,
     };
-    return this.hawkRequest("/fxa-oauth/token", "POST", payload).then(response => {
+    return this.hawkRequest(LOOP_SESSION_TYPE.FXA, "/fxa-oauth/token", "POST", payload).then(response => {
       return JSON.parse(response.body);
-    });
+    },
+    error => {this._hawkRequestError(error);});
   },
 
   /**
    * Called once gFxAOAuthClient fires onComplete.
    *
    * @param {Deferred} deferred used to resolve or reject the gFxAOAuthClientPromise
    * @param {Object} result (with code and state)
    */
@@ -620,44 +975,32 @@ let gInitializeTimerFunc = () => {
   },
   MozLoopServiceInternal.initialRegistrationDelayMilliseconds, Ci.nsITimer.TYPE_ONE_SHOT);
 };
 
 /**
  * Public API
  */
 this.MozLoopService = {
-#ifdef DEBUG
-  // Test-only helpers
-  get internal() {
-    return MozLoopServiceInternal;
-  },
-
-  get gFxAOAuthTokenData() {
-    return gFxAOAuthTokenData;
-  },
-
-  resetFxA: function() {
-    gFxAOAuthClientPromise = null;
-    gFxAOAuthClient = null;
-    gFxAOAuthTokenData = null;
-  },
-#endif
-
   _DNSService: gDNSService,
 
   set initializeTimerFunc(value) {
     gInitializeTimerFunc = value;
   },
 
   /**
    * Initialized the loop service, and starts registration with the
    * push and loop servers.
    */
   initialize: function() {
+
+    // Do this here, rather than immediately after definition, so that we can
+    // stub out API functions for unit testing
+    Object.freeze(this);
+
     // Don't do anything if loop is not enabled.
     if (!Services.prefs.getBoolPref("loop.enabled") ||
         Services.prefs.getBoolPref("loop.throttled")) {
       return;
     }
 
     // If expiresTime is in the future then kick-off registration.
     if (MozLoopServiceInternal.urlExpiryTimeIsInFuture()) {
@@ -734,17 +1077,17 @@ this.MozLoopService = {
       // Can't use bitwise operations here because JS treats all bitwise
       // operations as 32-bit *signed* integers.
       let now_serving = ((parseInt(address[1]) * 0x10000) +
                          (parseInt(address[2]) * 0x100) +
                          parseInt(address[3]));
 
       if (now_serving > ticket) {
         // Hot diggity! It's our turn! Activate the service.
-        console.log("MozLoopService: Activating Loop via soft-start");
+        log.info("MozLoopService: Activating Loop via soft-start");
         Services.prefs.setBoolPref("loop.throttled", false);
         buttonNode.hidden = false;
         this.initialize();
       }
       if (typeof(doneCb) == "function") {
         doneCb(null);
       }
     };
@@ -768,27 +1111,27 @@ this.MozLoopService = {
    * Starts registration of Loop with the push server, and then will register
    * 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) {
+  register: function(mockPushHandler, mockWebSocket) {
     // 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");
     }
 
-    return MozLoopServiceInternal.promiseRegisteredWithServers(mockPushHandler);
+    return MozLoopServiceInternal.promiseRegisteredWithServers(mockPushHandler, mockWebSocket);
   },
 
   /**
    * Used to note a call url expiry time. If the time is later than the current
    * latest expiry time, then the stored expiry time is increased. For times
    * sooner, this function is a no-op; this ensures we always have the latest
    * expiry time for a url.
    *
@@ -816,16 +1159,23 @@ this.MozLoopService = {
         Cu.reportError('No string for key: ' + key + 'found');
         return "";
       }
 
       return JSON.stringify(stringData[key]);
   },
 
   /**
+   * Returns a new GUID (UUID) in curly braces format.
+   */
+  generateUUID: function() {
+    return uuidgen.generateUUID().toString();
+  },
+
+  /**
    * Retrieves MozLoopService "do not disturb" value.
    *
    * @return {Boolean}
    */
   get doNotDisturb() {
     return MozLoopServiceInternal.doNotDisturb;
   },
 
@@ -833,16 +1183,20 @@ this.MozLoopService = {
    * Sets MozLoopService "do not disturb" value.
    *
    * @param {Boolean} aFlag
    */
   set doNotDisturb(aFlag) {
     MozLoopServiceInternal.doNotDisturb = aFlag;
   },
 
+  get userProfile() {
+    return gFxAOAuthProfile;
+  },
+
   get errors() {
     return MozLoopServiceInternal.errors;
   },
 
   /**
    * Returns the current locale
    *
    * @return {String} The code of the current locale.
@@ -852,28 +1206,61 @@ this.MozLoopService = {
       return Services.prefs.getComplexValue("general.useragent.locale",
         Ci.nsISupportsString).data;
     } catch (ex) {
       return "en-US";
     }
   },
 
   /**
+   * Returns the callData for a specific loopCallId
+   *
+   * The data was retrieved from the LoopServer via a GET/calls/<version> request
+   * triggered by an incoming message from the LoopPushServer.
+   *
+   * @param {int} loopCallId
+   * @return {callData} The callData or undefined if error.
+   */
+  getCallData: function(loopCallId) {
+    if (MozLoopServiceInternal.callsData.data &&
+        MozLoopServiceInternal.callsData.data.callId == loopCallId) {
+      return MozLoopServiceInternal.callsData.data;
+    } else {
+      return undefined;
+    }
+  },
+
+  /**
+   * Releases the callData for a specific loopCallId
+   *
+   * The result of this call will be a free call session slot.
+   *
+   * @param {int} loopCallId
+   */
+  releaseCallData: function(loopCallId) {
+    if (MozLoopServiceInternal.callsData.data &&
+        MozLoopServiceInternal.callsData.data.callId == loopCallId) {
+      MozLoopServiceInternal.callsData.data = undefined;
+      MozLoopServiceInternal.callsData.inUse = false;
+    }
+  },
+
+  /**
    * Set any character preference under "loop.".
    *
    * @param {String} prefName The name of the pref without the preceding "loop."
    * @param {String} value The value to set.
    *
    * Any errors thrown by the Mozilla pref API are logged to the console.
    */
   setLoopCharPref: function(prefName, value) {
     try {
       Services.prefs.setCharPref("loop." + prefName, value);
     } catch (ex) {
-      console.log("setLoopCharPref had trouble setting " + prefName +
+      log.error("setLoopCharPref had trouble setting " + prefName +
         "; exception: " + ex);
     }
   },
 
   /**
    * Return any preference under "loop." that's coercible to a character
    * preference.
    *
@@ -885,17 +1272,17 @@ this.MozLoopService = {
    * not being found.
    *
    * @return {String} on success, null on error
    */
   getLoopCharPref: function(prefName) {
     try {
       return Services.prefs.getCharPref("loop." + prefName);
     } catch (ex) {
-      console.log("getLoopCharPref had trouble getting " + prefName +
+      log.error("getLoopCharPref had trouble getting " + prefName +
         "; exception: " + ex);
       return null;
     }
   },
 
   /**
    * Return any preference under "loop." that's coercible to a character
    * preference.
@@ -908,56 +1295,113 @@ this.MozLoopService = {
    * not being found.
    *
    * @return {String} on success, null on error
    */
   getLoopBoolPref: function(prefName) {
     try {
       return Services.prefs.getBoolPref("loop." + prefName);
     } catch (ex) {
-      console.log("getLoopBoolPref had trouble getting " + prefName +
+      log.error("getLoopBoolPref had trouble getting " + prefName +
         "; exception: " + ex);
       return null;
     }
   },
 
   /**
    * Start the FxA login flow using the OAuth client and params from the Loop server.
    *
    * The caller should be prepared to handle rejections related to network, server or login errors.
    *
    * @return {Promise} that resolves when the FxA login flow is complete.
    */
   logInToFxA: function() {
+    log.debug("logInToFxA with gFxAOAuthTokenData:", !!gFxAOAuthTokenData);
     if (gFxAOAuthTokenData) {
       return Promise.resolve(gFxAOAuthTokenData);
     }
 
     return MozLoopServiceInternal.promiseFxAOAuthAuthorization().then(response => {
       return MozLoopServiceInternal.promiseFxAOAuthToken(response.code, response.state);
     }).then(tokenData => {
       gFxAOAuthTokenData = tokenData;
       return tokenData;
-    },
-    error => {
+    }).then(tokenData => {
+      return gRegisteredDeferred.promise.then(Task.async(function*() {
+        if (gPushHandler.pushUrl) {
+          yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, gPushHandler.pushUrl);
+        } else {
+          throw new Error("No pushUrl for FxA registration");
+        }
+        return gFxAOAuthTokenData;
+      }));
+    }).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);
+        gFxAOAuthProfile = null;
+        MozLoopServiceInternal.notifyStatusChanged();
+      });
+      return tokenData;
+    }).catch(error => {
       gFxAOAuthTokenData = null;
+      gFxAOAuthProfile = null;
       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);
+
+    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");
+  }),
+
+  openFxASettings: function() {
+    let url = new URL("/settings", gFxAOAuthClient.parameters.content_uri);
+    let win = Services.wm.getMostRecentWindow("navigator:browser");
+    win.switchToTabHavingURI(url.toString(), true);
+  },
+
+  /**
    * Performs a hawk based request to the loop server.
    *
+   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
+   *                                        One of the LOOP_SESSION_TYPE members.
    * @param {String} path The path to make the request to.
    * @param {String} method The request method, e.g. 'POST', 'GET'.
    * @param {Object} payloadObj An object which is converted to JSON and
    *                            transmitted with the request.
    * @returns {Promise}
    *        Returns a promise that resolves to the response of the API call,
    *        or is rejected with an error.  If the server response can be parsed
    *        as JSON and contains an 'error' property, the promise will be
    *        rejected with this JSON-parsed response.
    */
-  hawkRequest: function(path, method, payloadObj) {
-    return MozLoopServiceInternal.hawkRequest(path, method, payloadObj);
+  hawkRequest: function(sessionType, path, method, payloadObj) {
+    return MozLoopServiceInternal.hawkRequest(sessionType, path, method, payloadObj).catch(
+      error => {MozLoopServiceInternal._hawkRequestError(error);});
   },
 };
-Object.freeze(this.MozLoopService);
--- a/browser/components/loop/build-jsx
+++ b/browser/components/loop/build-jsx
@@ -1,25 +1,50 @@
 #! /usr/bin/env python
 
 import os
+import re
 from distutils import spawn
 import subprocess
 from threading import Thread
 import argparse
 
+def find_react_version(lib_dir):
+    "Finds the React library version number currently used."
+    for filename in os.listdir(lib_dir):
+        match = re.match(r"react-(.*)-prod\.js", filename)
+        if (match and match.group(1)):
+            return match.group(1)
+    print 'Unable to find the current react version used in content.'
+    print 'Please checked the %s directory.' % lib_dir
+    exit(1)
+
+SHARED_LIBS_DIR=os.path.join(os.path.dirname(__file__), "content", "shared", "libs")
+REACT_VERSION=find_react_version(SHARED_LIBS_DIR)
+
 src_files = []  # files to be compiled
 
 # search for react-tools install
 jsx_path = spawn.find_executable('jsx')
 if jsx_path is None:
     print 'You do not have the react-tools installed'
     print 'Please do $ npm install -g react-tools'
     exit(1)
 
+p = subprocess.Popen([jsx_path, '-V'],
+                     stdout=subprocess.PIPE,
+                     stderr=subprocess.STDOUT)
+for line in iter(p.stdout.readline, b''):
+    info = line.rstrip()
+
+if not info == REACT_VERSION:
+    print 'You have the wrong version of react-tools installed'
+    print 'Please use version %s' % REACT_VERSION
+    exit(1)
+
 # parse the CLI arguments
 description = 'Loop build tool for JSX files. ' + \
               'Will scan entire loop directory and compile them in place. ' + \
               'Must be executed from browser/components/loop directory.'
 
 parser = argparse.ArgumentParser(description=description)
 parser.add_argument('--watch', '-w', action='store_true', help='continuous' +
                     'build based on file changes (optional)')
--- a/browser/components/loop/content/conversation.html
+++ b/browser/components/loop/content/conversation.html
@@ -6,40 +6,32 @@
   <head>
     <meta charset="utf-8">
     <!-- Title is set in conversation.js -->
     <title></title>
     <link rel="stylesheet" type="text/css" href="loop/shared/css/reset.css">
     <link rel="stylesheet" type="text/css" href="loop/shared/css/common.css">
     <link rel="stylesheet" type="text/css" href="loop/shared/css/conversation.css">
  </head>
-  <body class="conversation-window" onload="loop.conversation.init();">
+  <body class="fx-embedded">
 
     <div id="messages"></div>
 
     <div id="main"></div>
 
     <script type="text/javascript" src="loop/libs/l10n.js"></script>
-    <script>
-      window.OTProperties = {
-        cdnURL: 'loop/',
-      };
-      window.OTProperties.assetURL = window.OTProperties.cdnURL + 'sdk-content/';
-      window.OTProperties.configURL = window.OTProperties.assetURL + 'js/dynamic_config.min.js';
-      window.OTProperties.cssURL = window.OTProperties.assetURL + 'css/ot.css';
-    </script>
+    <script type="text/javascript" src="loop/js/otconfig.js"></script>
     <script type="text/javascript" src="loop/libs/sdk.js"></script>
     <script type="text/javascript" src="loop/shared/libs/react-0.11.1.js"></script>
     <script type="text/javascript" src="loop/shared/libs/jquery-2.1.0.js"></script>
     <script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
 
     <script type="text/javascript" src="loop/shared/js/utils.js"></script>
     <script type="text/javascript" src="loop/shared/js/models.js"></script>
-    <script type="text/javascript" src="loop/shared/js/router.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/websocket.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
-    <script type="text/javascript" src="loop/js/desktopRouter.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
@@ -99,37 +99,51 @@ loop.Client = (function($) {
      * Internal handler for requesting a call url from the server.
      *
      * Callback parameters:
      * - err null on successful registration, non-null otherwise.
      * - callUrlData an object of the obtained call url data if successful:
      * -- callUrl: The url of the call
      * -- expiresAt: The amount of hours until expiry of the url
      *
-     * @param  {String} simplepushUrl a registered Simple Push URL
      * @param  {string} nickname the nickname of the future caller
      * @param  {Function} cb Callback(err, callUrlData)
      */
     _requestCallUrlInternal: function(nickname, cb) {
-      this.mozLoop.hawkRequest("/call-url/", "POST", {callerId: nickname},
-                               function (error, responseText) {
-        if (error) {
-          this._failureHandler(cb, error);
-          return;
-        }
+      var sessionType;
+      if (this.mozLoop.userProfile) {
+        sessionType = this.mozLoop.LOOP_SESSION_TYPE.FXA;
+      } else {
+        sessionType = this.mozLoop.LOOP_SESSION_TYPE.GUEST;
+      }
+
+      this.mozLoop.hawkRequest(sessionType, "/call-url/", "POST",
+                               {callerId: nickname},
+        function (error, responseText) {
+          if (error) {
+            this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", false);
+            this._failureHandler(cb, error);
+            return;
+          }
 
-        try {
-          var urlData = JSON.parse(responseText);
+          try {
+            var urlData = JSON.parse(responseText);
+
+            // This throws if the data is invalid, in which case only the failure
+            // telemetry will be recorded.
+            var returnData = this._validate(urlData, expectedCallUrlProperties);
 
-          cb(null, this._validate(urlData, expectedCallUrlProperties));
-        } catch (err) {
-          console.log("Error requesting call info", err);
-          cb(err);
-        }
-      }.bind(this));
+            this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", true);
+            cb(null, returnData);
+          } catch (err) {
+            this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", false);
+            console.log("Error requesting call info", err);
+            cb(err);
+          }
+        }.bind(this));
     },
 
     /**
      * Block call URL based on the token identifier
      *
      * @param {string} token Conversation identifier used to block the URL
      * @param {function} cb Callback function used for handling an error
      *                      response. XXX The incoming call panel does not
@@ -143,35 +157,41 @@ loop.Client = (function($) {
           return;
         }
 
         this._deleteCallUrlInternal(token, cb);
       }.bind(this));
     },
 
     _deleteCallUrlInternal: function(token, cb) {
-      this.mozLoop.hawkRequest("/call-url/" + token, "DELETE", null,
-                               function (error, responseText) {
+      function deleteRequestCallback(error, responseText) {
         if (error) {
           this._failureHandler(cb, error);
           return;
         }
 
         try {
           cb(null);
         } catch (err) {
           console.log("Error deleting call info", err);
           cb(err);
         }
-      }.bind(this));
+      }
+
+      // XXX hard-coding of GUEST to be removed by 1065155
+      this.mozLoop.hawkRequest(this.mozLoop.LOOP_SESSION_TYPE.GUEST,
+                               "/call-url/" + token, "DELETE", null,
+                               deleteRequestCallback.bind(this));
     },
 
     /**
      * Requests a call URL from the Loop server. It will note the
-     * expiry time for the url with the mozLoop api.
+     * expiry time for the url with the mozLoop api.  It will select the
+     * appropriate hawk session to use based on whether or not the user
+     * is currently logged into a Firefox account profile.
      *
      * Callback parameters:
      * - err null on successful registration, non-null otherwise.
      * - callUrlData an object of the obtained call url data if successful:
      * -- callUrl: The url of the call
      * -- expiresAt: The amount of hours until expiry of the url
      *
      * @param  {String} simplepushUrl a registered Simple Push URL
@@ -185,43 +205,24 @@ loop.Client = (function($) {
           return;
         }
 
         this._requestCallUrlInternal(nickname, cb);
       }.bind(this));
     },
 
     /**
-     * Requests call information from the server for all calls since the
-     * given version.
+     * Adds a value to a telemetry histogram, ignoring errors.
      *
-     * @param  {String} version the version identifier from the push
-     *                          notification
-     * @param  {Function} cb Callback(err, calls)
+     * @param  {string}  histogramId Name of the telemetry histogram to update.
+     * @param  {integer} value       Value to add to the histogram.
      */
-    requestCallsInfo: function(version, cb) {
-      // XXX It is likely that we'll want to move some of this to whatever
-      // opens the chat window, but we'll need to decide on this in bug 1002418
-      if (!version) {
-        throw new Error("missing required parameter version");
+    _telemetryAdd: function(histogramId, value) {
+      try {
+        this.mozLoop.telemetryAdd(histogramId, value);
+      } catch (err) {
+        console.error("Error recording telemetry", err);
       }
-
-      this.mozLoop.hawkRequest("/calls?version=" + version, "GET", null,
-                               function (error, responseText) {
-        if (error) {
-          this._failureHandler(cb, error);
-          return;
-        }
-
-        try {
-          var callsData = JSON.parse(responseText);
-
-          cb(null, this._validate(callsData, expectedCallProperties));
-        } catch (err) {
-          console.log("Error requesting calls info", err);
-          cb(err);
-        }
-      }.bind(this));
-    }
+    },
   };
 
   return Client;
 })(jQuery);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/contacts.js
@@ -0,0 +1,301 @@
+/** @jsx React.DOM */
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*jshint newcap:false*/
+/*global loop:true, React */
+
+var loop = loop || {};
+loop.contacts = (function(_, mozL10n) {
+  "use strict";
+
+  const Button = loop.shared.views.Button;
+  const ButtonGroup = loop.shared.views.ButtonGroup;
+
+  // Number of contacts to add to the list at the same time.
+  const CONTACTS_CHUNK_SIZE = 100;
+
+  const ContactDetail = React.createClass({displayName: 'ContactDetail',
+    propTypes: {
+      handleContactClick: React.PropTypes.func,
+      contact: React.PropTypes.object.isRequired
+    },
+
+    handleContactClick: function() {
+      if (this.props.handleContactClick) {
+        this.props.handleContactClick(this.props.key);
+      }
+    },
+
+    getContactNames: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      // NOTE: this method of finding a firstname and lastname is not i18n-proof.
+      let names = this.props.contact.name[0].split(" ");
+      return {
+        firstName: names.shift(),
+        lastName: names.join(" ")
+      };
+    },
+
+    getPreferredEmail: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      let email = this.props.contact.email[0];
+      this.props.contact.email.some(function(address) {
+        if (address.pref) {
+          email = address;
+          return true;
+        }
+        return false;
+      });
+      return email;
+    },
+
+    render: function() {
+      let names = this.getContactNames();
+      let email = this.getPreferredEmail();
+      let cx = React.addons.classSet;
+      let contactCSSClass = cx({
+        contact: true,
+        blocked: this.props.contact.blocked
+      });
+
+      return (
+        React.DOM.li({onClick: this.handleContactClick, className: contactCSSClass}, 
+          React.DOM.div({className: "avatar"}, 
+            React.DOM.img({src: navigator.mozLoop.getUserAvatar(email.value)})
+          ), 
+          React.DOM.div({className: "details"}, 
+            React.DOM.div({className: "username"}, React.DOM.strong(null, names.firstName), " ", names.lastName, 
+              React.DOM.i({className: cx({"icon icon-google": this.props.contact.category[0] == "google"})}), 
+              React.DOM.i({className: cx({"icon icon-blocked": this.props.contact.blocked})})
+            ), 
+            React.DOM.div({className: "email"}, email.value)
+          ), 
+          React.DOM.div({className: "icons"}, 
+            React.DOM.i({className: "icon icon-video"}), 
+            React.DOM.i({className: "icon icon-caret-down"})
+          )
+        )
+      );
+    }
+  });
+
+  const ContactsList = React.createClass({displayName: 'ContactsList',
+    getInitialState: function() {
+      return {
+        contacts: {}
+      };
+    },
+
+    componentDidMount: function() {
+      let contactsAPI = navigator.mozLoop.contacts;
+
+      contactsAPI.getAll((err, contacts) => {
+        if (err) {
+          throw err;
+        }
+
+        // Add contacts already present in the DB. We do this in timed chunks to
+        // circumvent blocking the main event loop.
+        let addContactsInChunks = () => {
+          contacts.splice(0, CONTACTS_CHUNK_SIZE).forEach(contact => {
+            this.handleContactAddOrUpdate(contact);
+          });
+          if (contacts.length) {
+            setTimeout(addContactsInChunks, 0);
+          }
+        };
+
+        addContactsInChunks(contacts);
+
+        // Listen for contact changes/ updates.
+        contactsAPI.on("add", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+        contactsAPI.on("remove", (eventName, contact) => {
+          this.handleContactRemove(contact);
+        });
+        contactsAPI.on("removeAll", () => {
+          this.handleContactRemoveAll();
+        });
+        contactsAPI.on("update", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+      });
+    },
+
+    handleContactAddOrUpdate: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      contacts[guid] = contact;
+      this.setState({});
+    },
+
+    handleContactRemove: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      if (!contacts[guid]) {
+        return;
+      }
+      delete contacts[guid];
+      this.setState({});
+    },
+
+    handleContactRemoveAll: function() {
+      this.setState({contacts: {}});
+    },
+
+    handleImportButtonClick: function() {
+    },
+
+    handleAddContactButtonClick: function() {
+      this.props.startForm("contacts_add");
+    },
+
+    sortContacts: function(contact1, contact2) {
+      let comp = contact1.name[0].localeCompare(contact2.name[0]);
+      if (comp !== 0) {
+        return comp;
+      }
+      // If names are equal, compare against unique ids to make sure we have
+      // consistent ordering.
+      return contact1._guid - contact2._guid;
+    },
+
+    render: function() {
+      let viewForItem = item => {
+        return ContactDetail({key: item._guid, contact: item})
+      };
+
+      let shownContacts = _.groupBy(this.state.contacts, function(contact) {
+        return contact.blocked ? "blocked" : "available";
+      });
+
+      // Buttons are temporarily hidden using "style".
+      return (
+        React.DOM.div(null, 
+          React.DOM.div({className: "content-area", style: {display: "none"}}, 
+            ButtonGroup(null, 
+              Button({caption: mozL10n.get("import_contacts_button"), 
+                      disabled: true, 
+                      onClick: this.handleImportButtonClick}), 
+              Button({caption: mozL10n.get("new_contact_button"), 
+                      onClick: this.handleAddContactButtonClick})
+            )
+          ), 
+          React.DOM.ul({className: "contact-list"}, 
+            shownContacts.available ?
+              shownContacts.available.sort(this.sortContacts).map(viewForItem) :
+              null, 
+            shownContacts.blocked ?
+              shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
+              null
+          )
+        )
+      );
+    }
+  });
+
+  const ContactDetailsForm = React.createClass({displayName: 'ContactDetailsForm',
+    mixins: [React.addons.LinkedStateMixin],
+
+    propTypes: {
+      mode: React.PropTypes.string
+    },
+
+    getInitialState: function() {
+      return {
+        contact: null,
+        pristine: true,
+        name: "",
+        email: "",
+      };
+    },
+
+    initForm: function(contact) {
+      let state = this.getInitialState();
+      state.contact = contact || null;
+      this.setState(state);
+    },
+
+    handleAcceptButtonClick: function() {
+      // Allow validity error indicators to be displayed.
+      this.setState({
+        pristine: false,
+      });
+
+      if (!this.refs.name.getDOMNode().checkValidity() ||
+          !this.refs.email.getDOMNode().checkValidity()) {
+        return;
+      }
+
+      this.props.selectTab("contacts");
+
+      let contactsAPI = navigator.mozLoop.contacts;
+
+      switch (this.props.mode) {
+        case "edit":
+          this.setState({
+            contact: null,
+          });
+          break;
+        case "add":
+          contactsAPI.add({
+            id: navigator.mozLoop.generateUUID(),
+            name: [this.state.name.trim()],
+            email: [{
+              pref: true,
+              type: ["home"],
+              value: this.state.email.trim()
+            }],
+            category: ["local"]
+          }, err => {
+            if (err) {
+              throw err;
+            }
+          });
+          break;
+      }
+    },
+
+    handleCancelButtonClick: function() {
+      this.props.selectTab("contacts");
+    },
+
+    render: function() {
+      let cx = React.addons.classSet;
+      return (
+        React.DOM.div({className: "content-area contact-form"}, 
+          React.DOM.header(null, mozL10n.get("add_contact_button")), 
+          React.DOM.label(null, mozL10n.get("edit_contact_name_label")), 
+          React.DOM.input({ref: "name", required: true, pattern: "\\s*\\S.*", 
+                 className: cx({pristine: this.state.pristine}), 
+                 valueLink: this.linkState("name")}), 
+          React.DOM.label(null, mozL10n.get("edit_contact_email_label")), 
+          React.DOM.input({ref: "email", required: true, type: "email", 
+                 className: cx({pristine: this.state.pristine}), 
+                 valueLink: this.linkState("email")}), 
+          ButtonGroup(null, 
+            Button({additionalClass: "button-cancel", 
+                    caption: mozL10n.get("cancel_button"), 
+                    onClick: this.handleCancelButtonClick}), 
+            Button({additionalClass: "button-accept", 
+                    caption: mozL10n.get("add_contact_button"), 
+                    onClick: this.handleAcceptButtonClick})
+          )
+        )
+      );
+    }
+  });
+
+  return {
+    ContactsList: ContactsList,
+    ContactDetailsForm: ContactDetailsForm,
+  };
+})(_, document.mozL10n);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -0,0 +1,301 @@
+/** @jsx React.DOM */
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*jshint newcap:false*/
+/*global loop:true, React */
+
+var loop = loop || {};
+loop.contacts = (function(_, mozL10n) {
+  "use strict";
+
+  const Button = loop.shared.views.Button;
+  const ButtonGroup = loop.shared.views.ButtonGroup;
+
+  // Number of contacts to add to the list at the same time.
+  const CONTACTS_CHUNK_SIZE = 100;
+
+  const ContactDetail = React.createClass({
+    propTypes: {
+      handleContactClick: React.PropTypes.func,
+      contact: React.PropTypes.object.isRequired
+    },
+
+    handleContactClick: function() {
+      if (this.props.handleContactClick) {
+        this.props.handleContactClick(this.props.key);
+      }
+    },
+
+    getContactNames: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      // NOTE: this method of finding a firstname and lastname is not i18n-proof.
+      let names = this.props.contact.name[0].split(" ");
+      return {
+        firstName: names.shift(),
+        lastName: names.join(" ")
+      };
+    },
+
+    getPreferredEmail: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      let email = this.props.contact.email[0];
+      this.props.contact.email.some(function(address) {
+        if (address.pref) {
+          email = address;
+          return true;
+        }
+        return false;
+      });
+      return email;
+    },
+
+    render: function() {
+      let names = this.getContactNames();
+      let email = this.getPreferredEmail();
+      let cx = React.addons.classSet;
+      let contactCSSClass = cx({
+        contact: true,
+        blocked: this.props.contact.blocked
+      });
+
+      return (
+        <li onClick={this.handleContactClick} className={contactCSSClass}>
+          <div className="avatar">
+            <img src={navigator.mozLoop.getUserAvatar(email.value)} />
+          </div>
+          <div className="details">
+            <div className="username"><strong>{names.firstName}</strong> {names.lastName}
+              <i className={cx({"icon icon-google": this.props.contact.category[0] == "google"})} />
+              <i className={cx({"icon icon-blocked": this.props.contact.blocked})} />
+            </div>
+            <div className="email">{email.value}</div>
+          </div>
+          <div className="icons">
+            <i className="icon icon-video" />
+            <i className="icon icon-caret-down" />
+          </div>
+        </li>
+      );
+    }
+  });
+
+  const ContactsList = React.createClass({
+    getInitialState: function() {
+      return {
+        contacts: {}
+      };
+    },
+
+    componentDidMount: function() {
+      let contactsAPI = navigator.mozLoop.contacts;
+
+      contactsAPI.getAll((err, contacts) => {
+        if (err) {
+          throw err;
+        }
+
+        // Add contacts already present in the DB. We do this in timed chunks to
+        // circumvent blocking the main event loop.
+        let addContactsInChunks = () => {
+          contacts.splice(0, CONTACTS_CHUNK_SIZE).forEach(contact => {
+            this.handleContactAddOrUpdate(contact);
+          });
+          if (contacts.length) {
+            setTimeout(addContactsInChunks, 0);
+          }
+        };
+
+        addContactsInChunks(contacts);
+
+        // Listen for contact changes/ updates.
+        contactsAPI.on("add", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+        contactsAPI.on("remove", (eventName, contact) => {
+          this.handleContactRemove(contact);
+        });
+        contactsAPI.on("removeAll", () => {
+          this.handleContactRemoveAll();
+        });
+        contactsAPI.on("update", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+      });
+    },
+
+    handleContactAddOrUpdate: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      contacts[guid] = contact;
+      this.setState({});
+    },
+
+    handleContactRemove: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      if (!contacts[guid]) {
+        return;
+      }
+      delete contacts[guid];
+      this.setState({});
+    },
+
+    handleContactRemoveAll: function() {
+      this.setState({contacts: {}});
+    },
+
+    handleImportButtonClick: function() {
+    },
+
+    handleAddContactButtonClick: function() {
+      this.props.startForm("contacts_add");
+    },
+
+    sortContacts: function(contact1, contact2) {
+      let comp = contact1.name[0].localeCompare(contact2.name[0]);
+      if (comp !== 0) {
+        return comp;
+      }
+      // If names are equal, compare against unique ids to make sure we have
+      // consistent ordering.
+      return contact1._guid - contact2._guid;
+    },
+
+    render: function() {
+      let viewForItem = item => {
+        return <ContactDetail key={item._guid} contact={item} />
+      };
+
+      let shownContacts = _.groupBy(this.state.contacts, function(contact) {
+        return contact.blocked ? "blocked" : "available";
+      });
+
+      // Buttons are temporarily hidden using "style".
+      return (
+        <div>
+          <div className="content-area" style={{display: "none"}}>
+            <ButtonGroup>
+              <Button caption={mozL10n.get("import_contacts_button")}
+                      disabled
+                      onClick={this.handleImportButtonClick} />
+              <Button caption={mozL10n.get("new_contact_button")}
+                      onClick={this.handleAddContactButtonClick} />
+            </ButtonGroup>
+          </div>
+          <ul className="contact-list">
+            {shownContacts.available ?
+              shownContacts.available.sort(this.sortContacts).map(viewForItem) :
+              null}
+            {shownContacts.blocked ?
+              shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
+              null}
+          </ul>
+        </div>
+      );
+    }
+  });
+
+  const ContactDetailsForm = React.createClass({
+    mixins: [React.addons.LinkedStateMixin],
+
+    propTypes: {
+      mode: React.PropTypes.string
+    },
+
+    getInitialState: function() {
+      return {
+        contact: null,
+        pristine: true,
+        name: "",
+        email: "",
+      };
+    },
+
+    initForm: function(contact) {
+      let state = this.getInitialState();
+      state.contact = contact || null;
+      this.setState(state);
+    },
+
+    handleAcceptButtonClick: function() {
+      // Allow validity error indicators to be displayed.
+      this.setState({
+        pristine: false,
+      });
+
+      if (!this.refs.name.getDOMNode().checkValidity() ||
+          !this.refs.email.getDOMNode().checkValidity()) {
+        return;
+      }
+
+      this.props.selectTab("contacts");
+
+      let contactsAPI = navigator.mozLoop.contacts;
+
+      switch (this.props.mode) {
+        case "edit":
+          this.setState({
+            contact: null,
+          });
+          break;
+        case "add":
+          contactsAPI.add({
+            id: navigator.mozLoop.generateUUID(),
+            name: [this.state.name.trim()],
+            email: [{
+              pref: true,
+              type: ["home"],
+              value: this.state.email.trim()
+            }],
+            category: ["local"]
+          }, err => {
+            if (err) {
+              throw err;
+            }
+          });
+          break;
+      }
+    },
+
+    handleCancelButtonClick: function() {
+      this.props.selectTab("contacts");
+    },
+
+    render: function() {
+      let cx = React.addons.classSet;
+      return (
+        <div className="content-area contact-form">
+          <header>{mozL10n.get("add_contact_button")}</header>
+          <label>{mozL10n.get("edit_contact_name_label")}</label>
+          <input ref="name" required pattern="\s*\S.*"
+                 className={cx({pristine: this.state.pristine})}
+                 valueLink={this.linkState("name")} />
+          <label>{mozL10n.get("edit_contact_email_label")}</label>
+          <input ref="email" required type="email"
+                 className={cx({pristine: this.state.pristine})}
+                 valueLink={this.linkState("email")} />
+          <ButtonGroup>
+            <Button additionalClass="button-cancel"
+                    caption={mozL10n.get("cancel_button")}
+                    onClick={this.handleCancelButtonClick} />
+            <Button additionalClass="button-accept"
+                    caption={mozL10n.get("add_contact_button")}
+                    onClick={this.handleAcceptButtonClick} />
+          </ButtonGroup>
+        </div>
+      );
+    }
+  });
+
+  return {
+    ContactsList: ContactsList,
+    ContactDetailsForm: ContactDetailsForm,
+  };
+})(_, document.mozL10n);
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -3,37 +3,38 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* jshint newcap:false, esnext:true */
 /* global loop:true, React */
 
 var loop = loop || {};
-loop.conversation = (function(OT, mozL10n) {
+loop.conversation = (function(mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views,
-      // aliasing translation function as __ for concision
-      __ = mozL10n.get;
-
-  /**
-   * App router.
-   * @type {loop.desktopRouter.DesktopConversationRouter}
-   */
-  var router;
+      sharedModels = loop.shared.models;
 
   var IncomingCallView = React.createClass({displayName: 'IncomingCallView',
 
     propTypes: {
-      model: React.PropTypes.object.isRequired
+      model: React.PropTypes.object.isRequired,
+      video: React.PropTypes.bool.isRequired
+    },
+
+    getDefaultProps: function() {
+      return {
+        showDeclineMenu: false,
+        video: true
+      };
     },
 
     getInitialState: function() {
-      return {showDeclineMenu: false};
+      return {showDeclineMenu: this.props.showDeclineMenu};
     },
 
     componentDidMount: function() {
       window.addEventListener("click", this.clickHandler);
       window.addEventListener("blur", this._hideDeclineMenu);
     },
 
     componentWillUnmount: function() {
@@ -70,211 +71,391 @@ loop.conversation = (function(OT, mozL10
       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"),
+        className: "fx-embedded-btn-icon-video",
+        tooltip: "incoming_call_accept_audio_video_tooltip"
+      };
+      var audioButton = {
+        handler: this._handleAccept("audio"),
+        className: "fx-embedded-btn-audio-small",
+        tooltip: "incoming_call_accept_audio_only_tooltip"
+      };
+      var props = {};
+      props.primary = videoButton;
+      props.secondary = audioButton;
+
+      // When video is not enabled on this call, we swap the buttons around.
+      if (!this.props.video) {
+        audioButton.className = "fx-embedded-btn-icon-audio";
+        videoButton.className = "fx-embedded-btn-video-small";
+        props.primary = audioButton;
+        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
       });
       return (
         React.DOM.div({className: conversationPanelClass}, 
-          React.DOM.h2(null, __("incoming_call_title2")), 
+          React.DOM.h2(null, mozL10n.get("incoming_call_title2")), 
           React.DOM.div({className: "btn-group incoming-call-action-group"}, 
 
             React.DOM.div({className: "fx-embedded-incoming-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, 
                           onClick: this._handleDecline}, 
-                    __("incoming_call_cancel_button")
+                    mozL10n.get("incoming_call_cancel_button")
                   ), 
                   React.DOM.div({className: "btn-chevron", 
                        onClick: this._toggleDeclineMenu}
                   )
                 ), 
 
                 React.DOM.ul({className: dropdownMenuClassesDecline}, 
                   React.DOM.li({className: "btn-block", onClick: this._handleDeclineBlock}, 
-                    __("incoming_call_cancel_and_block_button")
+                    mozL10n.get("incoming_call_cancel_and_block_button")
                   )
                 )
 
               )
             ), 
 
             React.DOM.div({className: "fx-embedded-incoming-call-button-spacer"}), 
 
-            React.DOM.div({className: "btn-chevron-menu-group"}, 
-              React.DOM.div({className: "btn-group"}, 
-                React.DOM.button({className: btnClassAccept, 
-                        onClick: this._handleAccept("audio-video")}, 
-                  React.DOM.span({className: "fx-embedded-answer-btn-text"}, 
-                    __("incoming_call_accept_button")
-                  ), 
-                  React.DOM.span({className: "fx-embedded-btn-icon-video"}
-                  )
-                ), 
-                React.DOM.div({className: "call-audio-only", 
-                     onClick: this._handleAccept("audio"), 
-                     title: __("incoming_call_accept_audio_only_tooltip")}
-                )
-              )
-            ), 
+            AcceptCallButton({mode: this._answerModeProps()}), 
 
             React.DOM.div({className: "fx-embedded-incoming-call-button-spacer"})
 
           )
         )
       );
       /* jshint ignore:end */
     }
   });
 
   /**
-   * Conversation router.
+   * Incoming call view accept button, renders different primary actions
+   * (answer with video / with audio only) based on the props received
+   **/
+  var AcceptCallButton = React.createClass({displayName: 'AcceptCallButton',
+
+    propTypes: {
+      mode: React.PropTypes.object.isRequired,
+    },
+
+    render: function() {
+      var mode = this.props.mode;
+      return (
+        /* jshint ignore:start */
+        React.DOM.div({className: "btn-chevron-menu-group"}, 
+          React.DOM.div({className: "btn-group"}, 
+            React.DOM.button({className: "btn btn-accept", 
+                    onClick: mode.primary.handler, 
+                    title: mozL10n.get(mode.primary.tooltip)}, 
+              React.DOM.span({className: "fx-embedded-answer-btn-text"}, 
+                mozL10n.get("incoming_call_accept_button")
+              ), 
+              React.DOM.span({className: mode.primary.className})
+            ), 
+            React.DOM.div({className: mode.secondary.className, 
+                 onClick: mode.secondary.handler, 
+                 title: mozL10n.get(mode.secondary.tooltip)}
+            )
+          )
+        )
+        /* jshint ignore:end */
+      );
+    }
+  });
+
+  /**
+   * This view manages the incoming conversation views - from
+   * call initiation through to the actual conversation and call end.
    *
-   * Required options:
-   * - {loop.shared.models.ConversationModel} conversation Conversation model.
-   * - {loop.shared.components.Notifier}      notifier     Notifier component.
-   *
-   * @type {loop.shared.router.BaseConversationRouter}
+   * At the moment, it does more than that, these parts need refactoring out.
    */
-  var ConversationRouter = loop.desktopRouter.DesktopConversationRouter.extend({
-    routes: {
-      "incoming/:version": "incoming",
-      "call/accept": "accept",
-      "call/decline": "decline",
-      "call/ongoing": "conversation",
-      "call/declineAndBlock": "declineAndBlock",
-      "call/feedback": "feedback"
+  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 {
+        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);
+      this.props.conversation.on("change:subscribedStream", this._checkConnected, this);
+      this.props.conversation.on("session:ended", this.endCall, this);
+      this.props.conversation.on("session:peer-hungup", this._onPeerHungup, this);
+      this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this);
+      this.props.conversation.on("session:connection-error", this._notifyError, this);
+
+      this.setupIncomingCall();
+    },
+
+    componentDidUnmount: function() {
+      this.props.conversation.off(null, null, this);
+    },
+
+    render: function() {
+      switch (this.state.callStatus) {
+        case "start": {
+          document.title = mozL10n.get("incoming_call_title2");
+
+          // XXX Don't render anything initially, though this should probably
+          // be some sort of pending view, whilst we connect the websocket.
+          return null;
+        }
+        case "incoming": {
+          document.title = mozL10n.get("incoming_call_title2");
+
+          return (
+            IncomingCallView({
+              model: this.props.conversation, 
+              video: this.props.conversation.hasVideoStream("incoming")}
+            )
+          );
+        }
+        case "connected": {
+          // XXX This should be the caller id (bug 1020449)
+          document.title = mozL10n.get("incoming_call_title2");
+
+          var callType = this.props.conversation.get("selectedCallType");
+
+          return (
+            sharedViews.ConversationView({
+              initiate: true, 
+              sdk: this.props.sdk, 
+              model: this.props.conversation, 
+              video: {enabled: callType !== "audio"}}
+            )
+          );
+        }
+        case "end": {
+          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"),
+            platform: appVersionInfo.OS,
+            channel: appVersionInfo.channel,
+            version: appVersionInfo.version
+          });
+
+          return (
+            sharedViews.FeedbackView({
+              feedbackApiClient: feedbackClient, 
+              onAfterFeedbackReceived: this.closeWindow.bind(this)}
+            )
+          );
+        }
+        case "close": {
+          window.close();
+          return (React.DOM.div(null));
+        }
+      }
     },
 
     /**
-     * @override {loop.shared.router.BaseConversationRouter.startCall}
+     * Notify the user that the connection was not possible
+     * @param {{code: number, message: string}} error
      */
-    startCall: function() {
-      this.navigate("call/ongoing", {trigger: true});
+    _notifyError: function(error) {
+      console.error(error);
+      this.props.notifications.errorL10n("connection_error_see_console_notification");
+      this.setState({callStatus: "end"});
     },
 
     /**
-     * @override {loop.shared.router.BaseConversationRouter.endCall}
+     * Peer hung up. Notifies the user and ends the call.
+     *
+     * Event properties:
+     * - {String} connectionId: OT session id
      */
-    endCall: function() {
-      this.navigate("call/feedback", {trigger: true});
+    _onPeerHungup: function() {
+      this.props.notifications.warnL10n("peer_ended_conversation2");
+      this.setState({callStatus: "end"});
+    },
+
+    /**
+     * Network disconnected. Notifies the user and ends the call.
+     */
+    _onNetworkDisconnected: function() {
+      this.props.notifications.warnL10n("network_disconnected");
+      this.setState({callStatus: "end"});
     },
 
     /**
      * Incoming call route.
-     *
-     * @param {String} loopVersion The version from the push notification, set
-     *                             by the router from the URL.
      */
-    incoming: function(loopVersion) {
+    setupIncomingCall: function() {
       navigator.mozLoop.startAlerting();
-      this._conversation.set({loopVersion: loopVersion});
-      this._conversation.once("accept", function() {
-        this.navigate("call/accept", {trigger: true});
-      }.bind(this));
-      this._conversation.once("decline", function() {
-        this.navigate("call/decline", {trigger: true});
-      }.bind(this));
-      this._conversation.once("declineAndBlock", function() {
-        this.navigate("call/declineAndBlock", {trigger: true});
-      }.bind(this));
-      this._conversation.once("call:incoming", this.startCall, this);
-      this._conversation.once("change:publishedStream", this._checkConnected, this);
-      this._conversation.once("change:subscribedStream", this._checkConnected, this);
+
+      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");
+        return;
+      }
+      this.props.conversation.setIncomingSessionData(callData);
+      this._setupWebSocket();
+    },
 
-      this._client.requestCallsInfo(loopVersion, function(err, sessionData) {
-        if (err) {
-          console.error("Failed to get the sessionData", err);
-          // XXX Not the ideal response, but bug 1047410 will be replacing
-          // this by better "call failed" UI.
-          this._notifier.errorL10n("cannot_start_call_session_not_ready");
-          return;
-        }
+    /**
+     * Starts the actual conversation
+     */
+    accepted: function() {
+      this.setState({callStatus: "connected"});
+    },
 
-        // XXX For incoming calls we might have more than one call queued.
-        // For now, we'll just assume the first call is the right information.
-        // We'll probably really want to be getting this data from the
-        // background worker on the desktop client.
-        // Bug 1032700 should fix this.
-        this._conversation.setIncomingSessionData(sessionData[0]);
-
-        this._setupWebSocketAndCallView();
-      }.bind(this));
+    /**
+     * Moves the call to the end state
+     */
+    endCall: function() {
+      navigator.mozLoop.releaseCallData(this.props.conversation.get("callId"));
+      this.setState({callStatus: "end"});
     },
 
     /**
      * Used to set up the web socket connection and navigate to the
      * call view if appropriate.
      */
-    _setupWebSocketAndCallView: function() {
+    _setupWebSocket: function() {
       this._websocket = new loop.CallConnectionWebSocket({
-        url: this._conversation.get("progressURL"),
-        websocketToken: this._conversation.get("websocketToken"),
-        callId: this._conversation.get("callId"),
+        url: this.props.conversation.get("progressURL"),
+        websocketToken: this.props.conversation.get("websocketToken"),
+        callId: this.props.conversation.get("callId"),
       });
       this._websocket.promiseConnect().then(function() {
-        this.loadReactComponent(loop.conversation.IncomingCallView({
-          model: this._conversation,
-          video: {enabled: this._conversation.hasVideoStream("incoming")}
-        }));
+        this.setState({callStatus: "incoming"});
       }.bind(this), function() {
         this._handleSessionError();
         return;
       }.bind(this));
+
+      this._websocket.on("progress", this._handleWebSocketProgress, this);
     },
 
     /**
      * Checks if the streams have been connected, and notifies the
      * websocket that the media is now connected.
      */
     _checkConnected: function() {
       // Check we've had both local and remote streams connected before
       // sending the media up message.
-      if (this._conversation.streamsConnected()) {
+      if (this.props.conversation.streamsConnected()) {
         this._websocket.mediaUp();
       }
     },
 
     /**
+     * Used to receive websocket progress and to determine how to handle
+     * it if appropraite.
+     * If we add more cases here, then we should refactor this function.
+     *
+     * @param {Object} progressData The progress data from the websocket.
+     * @param {String} previousState The previous state from the websocket.
+     */
+    _handleWebSocketProgress: function(progressData, previousState) {
+      // We only care about the terminated state at the moment.
+      if (progressData.state !== "terminated")
+        return;
+
+      if (progressData.reason === "cancel") {
+        this._abortIncomingCall();
+        return;
+      }
+
+      if (progressData.reason === "timeout" &&
+          (previousState === "init" || previousState === "alerting")) {
+        this._abortIncomingCall();
+      }
+    },
+
+    /**
+     * Silently aborts an incoming call - stops the alerting, and
+     * closes the websocket.
+     */
+    _abortIncomingCall: function() {
+      navigator.mozLoop.stopAlerting();
+      this._websocket.close();
+      // Having a timeout here lets the logging for the websocket complete and be
+      // displayed on the console if both are on.
+      setTimeout(this.closeWindow, 0);
+    },
+
+    closeWindow: function() {
+      window.close();
+    },
+
+    /**
      * Accepts an incoming call.
      */
     accept: function() {
       navigator.mozLoop.stopAlerting();
       this._websocket.accept();
-      this._conversation.incoming();
+      this.props.conversation.accepted();
     },
 
     /**
      * Declines a call and handles closing of the window.
      */
     _declineCall: function() {
       this._websocket.decline();
-      // XXX Don't close the window straight away, but let any sends happen
-      // first. Ideally we'd wait to close the window until after we have a
-      // response from the server, to know that everything has completed
-      // successfully. However, that's quite difficult to ensure at the
-      // moment so we'll add it later.
-      setTimeout(window.close, 0);
+      navigator.mozLoop.releaseCallData(this.props.conversation.get("callId"));
+      this._websocket.close();
+      // Having a timeout here lets the logging for the websocket complete and be
+      // displayed on the console if both are on.
+      setTimeout(this.closeWindow, 0);
     },
 
     /**
      * Declines an incoming call.
      */
     decline: function() {
       navigator.mozLoop.stopAlerting();
       this._declineCall();
@@ -283,103 +464,85 @@ loop.conversation = (function(OT, mozL10
     /**
      * Decline and block an incoming call
      * @note:
      * - loopToken is the callUrl identifier. It gets set in the panel
      *   after a callUrl is received
      */
     declineAndBlock: function() {
       navigator.mozLoop.stopAlerting();
-      var token = this._conversation.get("callToken");
-      this._client.deleteCallUrl(token, function(error) {
+      var token = this.props.conversation.get("callToken");
+      this.props.client.deleteCallUrl(token, function(error) {
         // XXX The conversation window will be closed when this cb is triggered
         // figure out if there is a better way to report the error to the user
         // (bug 1048909).
         console.log(error);
       });
       this._declineCall();
     },
 
     /**
-     * conversation is the route when the conversation is active. The start
-     * route should be navigated to first.
-     */
-    conversation: function() {
-      if (!this._conversation.isSessionReady()) {
-        console.error("Error: navigated to conversation route without " +
-          "the start route to initialise the call first");
-        this._handleSessionError();
-        return;
-      }
-
-      var callType = this._conversation.get("selectedCallType");
-      var videoStream = callType === "audio" ? false : true;
-
-      /*jshint newcap:false*/
-      this.loadReactComponent(sharedViews.ConversationView({
-        sdk: OT,
-        model: this._conversation,
-        video: {enabled: videoStream}
-      }));
-    },
-
-    /**
      * 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._notifier.errorL10n("cannot_start_call_session_not_ready");
+      this.props.notifications.errorL10n("cannot_start_call_session_not_ready");
     },
-
-    /**
-     * Call has ended, display a feedback form.
-     */
-    feedback: function() {
-      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"),
-        platform: appVersionInfo.OS,
-        channel: appVersionInfo.channel,
-        version: appVersionInfo.version
-      });
-
-      this.loadReactComponent(sharedViews.FeedbackView({
-        feedbackApiClient: feedbackClient
-      }));
-    }
   });
 
   /**
    * Panel 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);
 
-    document.title = mozL10n.get("incoming_call_title2");
+    // Plug in an alternate client ID mechanism, as localStorage and cookies
+    // don't work in the conversation window
+    window.OT.overrideGuidStorage({
+      get: function(callback) {
+        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 client = new loop.Client();
-    router = new ConversationRouter({
-      client: client,
-      conversation: new loop.shared.models.ConversationModel(
-        {},         // Model attributes
-        {sdk: OT}), // Model dependencies
-      notifier: new sharedViews.NotificationListView({el: "#messages"})
+    var conversation = new sharedModels.ConversationModel(
+      {},                // Model attributes
+      {sdk: window.OT}   // Model dependencies
+    );
+    var notifications = new sharedModels.NotificationCollection();
+
+    window.addEventListener("unload", function(event) {
+      // Handle direct close of dialog box via [x] control.
+      navigator.mozLoop.releaseCallData(conversation.get("callId"));
     });
-    Backbone.history.start();
+
+    // 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]);
+    }
+
+    React.renderComponent(IncomingConversationView({
+      client: client, 
+      conversation: conversation, 
+      notifications: notifications, 
+      sdk: window.OT}
+    ), document.querySelector('#main'));
   }
 
   return {
-    ConversationRouter: ConversationRouter,
+    IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     init: init
   };
-})(window.OT, document.mozL10n);
+})(document.mozL10n);
+
+document.addEventListener('DOMContentLoaded', loop.conversation.init);
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -3,37 +3,38 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* jshint newcap:false, esnext:true */
 /* global loop:true, React */
 
 var loop = loop || {};
-loop.conversation = (function(OT, mozL10n) {
+loop.conversation = (function(mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views,
-      // aliasing translation function as __ for concision
-      __ = mozL10n.get;
-
-  /**
-   * App router.
-   * @type {loop.desktopRouter.DesktopConversationRouter}
-   */
-  var router;
+      sharedModels = loop.shared.models;
 
   var IncomingCallView = React.createClass({
 
     propTypes: {
-      model: React.PropTypes.object.isRequired
+      model: React.PropTypes.object.isRequired,
+      video: React.PropTypes.bool.isRequired
+    },
+
+    getDefaultProps: function() {
+      return {
+        showDeclineMenu: false,
+        video: true
+      };
     },
 
     getInitialState: function() {
-      return {showDeclineMenu: false};
+      return {showDeclineMenu: this.props.showDeclineMenu};
     },
 
     componentDidMount: function() {
       window.addEventListener("click", this.clickHandler);
       window.addEventListener("blur", this._hideDeclineMenu);
     },
 
     componentWillUnmount: function() {
@@ -70,211 +71,391 @@ loop.conversation = (function(OT, mozL10
       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"),
+        className: "fx-embedded-btn-icon-video",
+        tooltip: "incoming_call_accept_audio_video_tooltip"
+      };
+      var audioButton = {
+        handler: this._handleAccept("audio"),
+        className: "fx-embedded-btn-audio-small",
+        tooltip: "incoming_call_accept_audio_only_tooltip"
+      };
+      var props = {};
+      props.primary = videoButton;
+      props.secondary = audioButton;
+
+      // When video is not enabled on this call, we swap the buttons around.
+      if (!this.props.video) {
+        audioButton.className = "fx-embedded-btn-icon-audio";
+        videoButton.className = "fx-embedded-btn-video-small";
+        props.primary = audioButton;
+        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
       });
       return (
         <div className={conversationPanelClass}>
-          <h2>{__("incoming_call_title2")}</h2>
+          <h2>{mozL10n.get("incoming_call_title2")}</h2>
           <div className="btn-group incoming-call-action-group">
 
             <div className="fx-embedded-incoming-call-button-spacer"></div>
 
             <div className="btn-chevron-menu-group">
               <div className="btn-group-chevron">
                 <div className="btn-group">
 
                   <button className={btnClassDecline}
                           onClick={this._handleDecline}>
-                    {__("incoming_call_cancel_button")}
+                    {mozL10n.get("incoming_call_cancel_button")}
                   </button>
                   <div className="btn-chevron"
                        onClick={this._toggleDeclineMenu}>
                   </div>
                 </div>
 
                 <ul className={dropdownMenuClassesDecline}>
                   <li className="btn-block" onClick={this._handleDeclineBlock}>
-                    {__("incoming_call_cancel_and_block_button")}
+                    {mozL10n.get("incoming_call_cancel_and_block_button")}
                   </li>
                 </ul>
 
               </div>
             </div>
 
             <div className="fx-embedded-incoming-call-button-spacer"></div>
 
-            <div className="btn-chevron-menu-group">
-              <div className="btn-group">
-                <button className={btnClassAccept}
-                        onClick={this._handleAccept("audio-video")}>
-                  <span className="fx-embedded-answer-btn-text">
-                    {__("incoming_call_accept_button")}
-                  </span>
-                  <span className="fx-embedded-btn-icon-video">
-                  </span>
-                </button>
-                <div className="call-audio-only"
-                     onClick={this._handleAccept("audio")}
-                     title={__("incoming_call_accept_audio_only_tooltip")} >
-                </div>
-              </div>
-            </div>
+            <AcceptCallButton mode={this._answerModeProps()} />
 
             <div className="fx-embedded-incoming-call-button-spacer"></div>
 
           </div>
         </div>
       );
       /* jshint ignore:end */
     }
   });
 
   /**
-   * Conversation router.
+   * Incoming call view accept button, renders different primary actions
+   * (answer with video / with audio only) based on the props received
+   **/
+  var AcceptCallButton = React.createClass({
+
+    propTypes: {
+      mode: React.PropTypes.object.isRequired,
+    },
+
+    render: function() {
+      var mode = this.props.mode;
+      return (
+        /* jshint ignore:start */
+        <div className="btn-chevron-menu-group">
+          <div className="btn-group">
+            <button className="btn btn-accept"
+                    onClick={mode.primary.handler}
+                    title={mozL10n.get(mode.primary.tooltip)}>
+              <span className="fx-embedded-answer-btn-text">
+                {mozL10n.get("incoming_call_accept_button")}
+              </span>
+              <span className={mode.primary.className}></span>
+            </button>
+            <div className={mode.secondary.className}
+                 onClick={mode.secondary.handler}
+                 title={mozL10n.get(mode.secondary.tooltip)}>
+            </div>
+          </div>
+        </div>
+        /* jshint ignore:end */
+      );
+    }
+  });
+
+  /**
+   * This view manages the incoming conversation views - from
+   * call initiation through to the actual conversation and call end.
    *
-   * Required options:
-   * - {loop.shared.models.ConversationModel} conversation Conversation model.
-   * - {loop.shared.components.Notifier}      notifier     Notifier component.
-   *
-   * @type {loop.shared.router.BaseConversationRouter}
+   * At the moment, it does more than that, these parts need refactoring out.
    */
-  var ConversationRouter = loop.desktopRouter.DesktopConversationRouter.extend({
-    routes: {
-      "incoming/:version": "incoming",
-      "call/accept": "accept",
-      "call/decline": "decline",
-      "call/ongoing": "conversation",
-      "call/declineAndBlock": "declineAndBlock",
-      "call/feedback": "feedback"
+  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 {
+        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);
+      this.props.conversation.on("change:subscribedStream", this._checkConnected, this);
+      this.props.conversation.on("session:ended", this.endCall, this);
+      this.props.conversation.on("session:peer-hungup", this._onPeerHungup, this);
+      this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this);
+      this.props.conversation.on("session:connection-error", this._notifyError, this);
+
+      this.setupIncomingCall();
+    },
+
+    componentDidUnmount: function() {
+      this.props.conversation.off(null, null, this);
+    },
+
+    render: function() {
+      switch (this.state.callStatus) {
+        case "start": {
+          document.title = mozL10n.get("incoming_call_title2");
+
+          // XXX Don't render anything initially, though this should probably
+          // be some sort of pending view, whilst we connect the websocket.
+          return null;
+        }
+        case "incoming": {
+          document.title = mozL10n.get("incoming_call_title2");
+
+          return (
+            <IncomingCallView
+              model={this.props.conversation}
+              video={this.props.conversation.hasVideoStream("incoming")}
+            />
+          );
+        }
+        case "connected": {
+          // XXX This should be the caller id (bug 1020449)
+          document.title = mozL10n.get("incoming_call_title2");
+
+          var callType = this.props.conversation.get("selectedCallType");
+
+          return (
+            <sharedViews.ConversationView
+              initiate={true}
+              sdk={this.props.sdk}
+              model={this.props.conversation}
+              video={{enabled: callType !== "audio"}}
+            />
+          );
+        }
+        case "end": {
+          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"),
+            platform: appVersionInfo.OS,
+            channel: appVersionInfo.channel,
+            version: appVersionInfo.version
+          });
+
+          return (
+            <sharedViews.FeedbackView
+              feedbackApiClient={feedbackClient}
+              onAfterFeedbackReceived={this.closeWindow.bind(this)}
+            />
+          );
+        }
+        case "close": {
+          window.close();
+          return (<div/>);
+        }
+      }
     },
 
     /**
-     * @override {loop.shared.router.BaseConversationRouter.startCall}
+     * Notify the user that the connection was not possible
+     * @param {{code: number, message: string}} error
      */
-    startCall: function() {
-      this.navigate("call/ongoing", {trigger: true});
+    _notifyError: function(error) {
+      console.error(error);
+      this.props.notifications.errorL10n("connection_error_see_console_notification");
+      this.setState({callStatus: "end"});
     },
 
     /**
-     * @override {loop.shared.router.BaseConversationRouter.endCall}
+     * Peer hung up. Notifies the user and ends the call.
+     *
+     * Event properties:
+     * - {String} connectionId: OT session id
      */
-    endCall: function() {
-      this.navigate("call/feedback", {trigger: true});
+    _onPeerHungup: function() {
+      this.props.notifications.warnL10n("peer_ended_conversation2");
+      this.setState({callStatus: "end"});
+    },
+
+    /**
+     * Network disconnected. Notifies the user and ends the call.
+     */
+    _onNetworkDisconnected: function() {
+      this.props.notifications.warnL10n("network_disconnected");
+      this.setState({callStatus: "end"});
     },
 
     /**
      * Incoming call route.
-     *
-     * @param {String} loopVersion The version from the push notification, set
-     *                             by the router from the URL.
      */
-    incoming: function(loopVersion) {
+    setupIncomingCall: function() {
       navigator.mozLoop.startAlerting();
-      this._conversation.set({loopVersion: loopVersion});
-      this._conversation.once("accept", function() {
-        this.navigate("call/accept", {trigger: true});
-      }.bind(this));
-      this._conversation.once("decline", function() {
-        this.navigate("call/decline", {trigger: true});
-      }.bind(this));
-      this._conversation.once("declineAndBlock", function() {
-        this.navigate("call/declineAndBlock", {trigger: true});
-      }.bind(this));
-      this._conversation.once("call:incoming", this.startCall, this);
-      this._conversation.once("change:publishedStream", this._checkConnected, this);
-      this._conversation.once("change:subscribedStream", this._checkConnected, this);
+
+      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");
+        return;
+      }
+      this.props.conversation.setIncomingSessionData(callData);
+      this._setupWebSocket();
+    },
 
-      this._client.requestCallsInfo(loopVersion, function(err, sessionData) {
-        if (err) {
-          console.error("Failed to get the sessionData", err);
-          // XXX Not the ideal response, but bug 1047410 will be replacing
-          // this by better "call failed" UI.
-          this._notifier.errorL10n("cannot_start_call_session_not_ready");
-          return;
-        }
+    /**
+     * Starts the actual conversation
+     */
+    accepted: function() {
+      this.setState({callStatus: "connected"});
+    },
 
-        // XXX For incoming calls we might have more than one call queued.
-        // For now, we'll just assume the first call is the right information.
-        // We'll probably really want to be getting this data from the
-        // background worker on the desktop client.
-        // Bug 1032700 should fix this.
-        this._conversation.setIncomingSessionData(sessionData[0]);
-
-        this._setupWebSocketAndCallView();
-      }.bind(this));
+    /**
+     * Moves the call to the end state
+     */
+    endCall: function() {
+      navigator.mozLoop.releaseCallData(this.props.conversation.get("callId"));
+      this.setState({callStatus: "end"});
     },
 
     /**
      * Used to set up the web socket connection and navigate to the
      * call view if appropriate.
      */
-    _setupWebSocketAndCallView: function() {
+    _setupWebSocket: function() {
       this._websocket = new loop.CallConnectionWebSocket({
-        url: this._conversation.get("progressURL"),
-        websocketToken: this._conversation.get("websocketToken"),
-        callId: this._conversation.get("callId"),
+        url: this.props.conversation.get("progressURL"),
+        websocketToken: this.props.conversation.get("websocketToken"),
+        callId: this.props.conversation.get("callId"),
       });
       this._websocket.promiseConnect().then(function() {
-        this.loadReactComponent(loop.conversation.IncomingCallView({
-          model: this._conversation,
-          video: {enabled: this._conversation.hasVideoStream("incoming")}
-        }));
+        this.setState({callStatus: "incoming"});
       }.bind(this), function() {
         this._handleSessionError();
         return;
       }.bind(this));
+
+      this._websocket.on("progress", this._handleWebSocketProgress, this);
     },
 
     /**
      * Checks if the streams have been connected, and notifies the
      * websocket that the media is now connected.
      */
     _checkConnected: function() {
       // Check we've had both local and remote streams connected before
       // sending the media up message.
-      if (this._conversation.streamsConnected()) {
+      if (this.props.conversation.streamsConnected()) {
         this._websocket.mediaUp();
       }
     },
 
     /**
+     * Used to receive websocket progress and to determine how to handle
+     * it if appropraite.
+     * If we add more cases here, then we should refactor this function.
+     *
+     * @param {Object} progressData The progress data from the websocket.
+     * @param {String} previousState The previous state from the websocket.
+     */
+    _handleWebSocketProgress: function(progressData, previousState) {
+      // We only care about the terminated state at the moment.
+      if (progressData.state !== "terminated")
+        return;
+
+      if (progressData.reason === "cancel") {
+        this._abortIncomingCall();
+        return;
+      }
+
+      if (progressData.reason === "timeout" &&
+          (previousState === "init" || previousState === "alerting")) {
+        this._abortIncomingCall();
+      }
+    },
+
+    /**
+     * Silently aborts an incoming call - stops the alerting, and
+     * closes the websocket.
+     */
+    _abortIncomingCall: function() {
+      navigator.mozLoop.stopAlerting();
+      this._websocket.close();
+      // Having a timeout here lets the logging for the websocket complete and be
+      // displayed on the console if both are on.
+      setTimeout(this.closeWindow, 0);
+    },
+
+    closeWindow: function() {
+      window.close();
+    },
+
+    /**
      * Accepts an incoming call.
      */
     accept: function() {
       navigator.mozLoop.stopAlerting();
       this._websocket.accept();
-      this._conversation.incoming();
+      this.props.conversation.accepted();
     },
 
     /**
      * Declines a call and handles closing of the window.
      */
     _declineCall: function() {
       this._websocket.decline();
-      // XXX Don't close the window straight away, but let any sends happen
-      // first. Ideally we'd wait to close the window until after we have a
-      // response from the server, to know that everything has completed
-      // successfully. However, that's quite difficult to ensure at the
-      // moment so we'll add it later.
-      setTimeout(window.close, 0);
+      navigator.mozLoop.releaseCallData(this.props.conversation.get("callId"));
+      this._websocket.close();
+      // Having a timeout here lets the logging for the websocket complete and be
+      // displayed on the console if both are on.
+      setTimeout(this.closeWindow, 0);
     },
 
     /**
      * Declines an incoming call.
      */
     decline: function() {
       navigator.mozLoop.stopAlerting();
       this._declineCall();
@@ -283,103 +464,85 @@ loop.conversation = (function(OT, mozL10
     /**
      * Decline and block an incoming call
      * @note:
      * - loopToken is the callUrl identifier. It gets set in the panel
      *   after a callUrl is received
      */
     declineAndBlock: function() {
       navigator.mozLoop.stopAlerting();
-      var token = this._conversation.get("callToken");
-      this._client.deleteCallUrl(token, function(error) {
+      var token = this.props.conversation.get("callToken");
+      this.props.client.deleteCallUrl(token, function(error) {
         // XXX The conversation window will be closed when this cb is triggered
         // figure out if there is a better way to report the error to the user
         // (bug 1048909).
         console.log(error);
       });
       this._declineCall();
     },
 
     /**
-     * conversation is the route when the conversation is active. The start
-     * route should be navigated to first.
-     */
-    conversation: function() {
-      if (!this._conversation.isSessionReady()) {
-        console.error("Error: navigated to conversation route without " +
-          "the start route to initialise the call first");
-        this._handleSessionError();
-        return;
-      }
-
-      var callType = this._conversation.get("selectedCallType");
-      var videoStream = callType === "audio" ? false : true;
-
-      /*jshint newcap:false*/
-      this.loadReactComponent(sharedViews.ConversationView({
-        sdk: OT,
-        model: this._conversation,
-        video: {enabled: videoStream}
-      }));
-    },
-
-    /**
      * 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._notifier.errorL10n("cannot_start_call_session_not_ready");
+      this.props.notifications.errorL10n("cannot_start_call_session_not_ready");
     },
-
-    /**
-     * Call has ended, display a feedback form.
-     */
-    feedback: function() {
-      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"),
-        platform: appVersionInfo.OS,
-        channel: appVersionInfo.channel,
-        version: appVersionInfo.version
-      });
-
-      this.loadReactComponent(sharedViews.FeedbackView({
-        feedbackApiClient: feedbackClient
-      }));
-    }
   });
 
   /**
    * Panel 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);
 
-    document.title = mozL10n.get("incoming_call_title2");
+    // Plug in an alternate client ID mechanism, as localStorage and cookies
+    // don't work in the conversation window
+    window.OT.overrideGuidStorage({
+      get: function(callback) {
+        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 client = new loop.Client();
-    router = new ConversationRouter({
-      client: client,
-      conversation: new loop.shared.models.ConversationModel(
-        {},         // Model attributes
-        {sdk: OT}), // Model dependencies
-      notifier: new sharedViews.NotificationListView({el: "#messages"})
+    var conversation = new sharedModels.ConversationModel(
+      {},                // Model attributes
+      {sdk: window.OT}   // Model dependencies
+    );
+    var notifications = new sharedModels.NotificationCollection();
+
+    window.addEventListener("unload", function(event) {
+      // Handle direct close of dialog box via [x] control.
+      navigator.mozLoop.releaseCallData(conversation.get("callId"));
     });
-    Backbone.history.start();
+
+    // 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]);
+    }
+
+    React.renderComponent(<IncomingConversationView
+      client={client}
+      conversation={conversation}
+      notifications={notifications}
+      sdk={window.OT}
+    />, document.querySelector('#main'));
   }
 
   return {
-    ConversationRouter: ConversationRouter,
+    IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     init: init
   };
-})(window.OT, document.mozL10n);
+})(document.mozL10n);
+
+document.addEventListener('DOMContentLoaded', loop.conversation.init);
deleted file mode 100644
--- a/browser/components/loop/content/js/desktopRouter.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this file,
- * You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-/* jshint esnext:true */
-/* global loop:true */
-
-var loop = loop || {};
-loop.desktopRouter = (function() {
-  "use strict";
-
-  /**
-   * On the desktop app, the use of about: uris prevents us from changing the
-   * url of the location. As a result, we change the navigate function to simply
-   * activate the new routes, and not try changing the url.
-   *
-   * XXX It is conceivable we might be able to remove this in future, if we
-   * can either swap to resource uris or remove the limitation on the about uris.
-   */
-  var extendedRouter = {
-    navigate: function(to) {
-      this[this.routes[to]]();
-    }
-  };
-
-  var DesktopRouter = loop.shared.router.BaseRouter.extend(extendedRouter);
-
-  var DesktopConversationRouter =
-    loop.shared.router.BaseConversationRouter.extend(extendedRouter);
-
-  return {
-    DesktopRouter: DesktopRouter,
-    DesktopConversationRouter: DesktopConversationRouter
-  };
-})();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/otconfig.js
@@ -0,0 +1,10 @@
+/* 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/. */
+
+window.OTProperties = {
+  cdnURL: 'loop/',
+};
+window.OTProperties.assetURL = window.OTProperties.cdnURL + 'sdk-content/';
+window.OTProperties.configURL = window.OTProperties.assetURL + 'js/dynamic_config.min.js';
+window.OTProperties.cssURL = window.OTProperties.assetURL + 'css/ot.css';
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -6,61 +6,78 @@
 
 /*jshint newcap:false*/
 /*global loop:true, React */
 
 var loop = loop || {};
 loop.panel = (function(_, mozL10n) {
   "use strict";
 
-  var sharedViews = loop.shared.views,
-      // aliasing translation function as __ for concision
-      __ = mozL10n.get;
+  var sharedViews = loop.shared.views;
+  var sharedModels = loop.shared.models;
+  var sharedMixins = loop.shared.mixins;
+  var Button = sharedViews.Button;
+  var ButtonGroup = sharedViews.ButtonGroup;
+  var ContactsList = loop.contacts.ContactsList;
+  var ContactDetailsForm = loop.contacts.ContactDetailsForm;
+  var __ = mozL10n.get; // aliasing translation function as __ for concision
 
-  /**
-   * Panel router.
-   * @type {loop.desktopRouter.DesktopRouter}
-   */
-  var router;
+  var TabView = React.createClass({displayName: 'TabView',
+    getInitialState: function() {
+      return {
+        selectedTab: "call"
+      };
+    },
 
-  /**
-   * Dropdown menu mixin.
-   * @type {Object}
-   */
-  var DropdownMenuMixin = {
-    getInitialState: function() {
-      return {showMenu: false};
+    handleSelectTab: function(event) {
+      var tabName = event.target.dataset.tabName;
+      this.setState({selectedTab: tabName});
     },
 
-    _onBodyClick: function() {
-      this.setState({showMenu: false});
-    },
-
-    componentDidMount: function() {
-      document.body.addEventListener("click", this._onBodyClick);
-    },
+    render: function() {
+      var cx = React.addons.classSet;
+      var tabButtons = [];
+      var tabs = [];
+      React.Children.forEach(this.props.children, function(tab, i) {
+        var tabName = tab.props.name;
+        var isSelected = (this.state.selectedTab == tabName);
+        if (!tab.props.hidden) {
+          tabButtons.push(
+            React.DOM.li({className: cx({selected: isSelected}), 
+                key: i, 
+                'data-tab-name': tabName, 
+                onClick: this.handleSelectTab})
+          );
+        }
+        tabs.push(
+          React.DOM.div({key: i, className: cx({tab: true, selected: isSelected})}, 
+            tab.props.children
+          )
+        );
+      }, this);
+      return (
+        React.DOM.div({className: "tab-view-container"}, 
+          React.DOM.ul({className: "tab-view"}, tabButtons), 
+          tabs
+        )
+      );
+    }
+  });
 
-    componentWillUnmount: function() {
-      document.body.removeEventListener("click", this._onBodyClick);
-    },
-
-    showDropdownMenu: function() {
-      this.setState({showMenu: true});
-    },
-
-    hideDropdownMenu: function() {
-      this.setState({showMenu: false});
+  var Tab = React.createClass({displayName: 'Tab',
+    render: function() {
+      return null;
     }
-  };
+  });
 
   /**
    * Availability drop down menu subview.
    */
   var AvailabilityDropdown = React.createClass({displayName: 'AvailabilityDropdown',
-    mixins: [DropdownMenuMixin],
+    mixins: [sharedMixins.DropdownMenuMixin],
 
     getInitialState: function() {
       return {
         doNotDisturb: navigator.mozLoop.doNotDisturb
       };
     },
 
     // XXX target event can either be the li, the span or the i tag
@@ -182,50 +199,49 @@ loop.panel = (function(_, mozL10n) {
       );
     }
   });
 
   /**
    * Panel settings (gear) menu.
    */
   var SettingsDropdown = React.createClass({displayName: 'SettingsDropdown',
-    mixins: [DropdownMenuMixin],
+    mixins: [sharedMixins.DropdownMenuMixin],
 
     handleClickSettingsEntry: function() {
-      // XXX to be implemented
+      // XXX to be implemented at the same time as unhiding the entry
     },
 
     handleClickAccountEntry: function() {
-      // XXX to be implemented
+      navigator.mozLoop.openFxASettings();
     },
 
     handleClickAuthEntry: function() {
       if (this._isSignedIn()) {
-        // XXX to be implemented - bug 979845
         navigator.mozLoop.logOutFromFxA();
       } else {
         navigator.mozLoop.logInToFxA();
       }
     },
 
     _isSignedIn: function() {
-      // XXX to be implemented - bug 979845
-      return !!navigator.mozLoop.loggedInToFxA;
+      return !!navigator.mozLoop.userProfile;
     },
 
     render: function() {
       var cx = React.addons.classSet;
       return (
         React.DOM.div({className: "settings-menu dropdown"}, 
-          React.DOM.a({className: "btn btn-settings", onClick: this.showDropdownMenu, 
+          React.DOM.a({className: "button-settings", onClick: this.showDropdownMenu, 
              title: __("settings_menu_button_tooltip")}), 
           React.DOM.ul({className: cx({"dropdown-menu": true, hide: !this.state.showMenu}), 
               onMouseLeave: this.hideDropdownMenu}, 
             SettingsDropdownEntry({label: __("settings_menu_item_settings"), 
                                    onClick: this.handleClickSettingsEntry, 
+                                   displayed: false, 
                                    icon: "settings"}), 
             SettingsDropdownEntry({label: __("settings_menu_item_account"), 
                                    onClick: this.handleClickAccountEntry, 
                                    icon: "account", 
                                    displayed: this._isSignedIn()}), 
             SettingsDropdownEntry({label: this._isSignedIn() ?
                                           __("settings_menu_item_signout") :
                                           __("settings_menu_item_signin"), 
@@ -233,109 +249,103 @@ loop.panel = (function(_, mozL10n) {
                                    icon: this._isSignedIn() ? "signout" : "signin"})
           )
         )
       );
     }
   });
 
   /**
-   * Panel layout.
+   * Call url result view.
    */
-  var PanelLayout = React.createClass({displayName: 'PanelLayout',
-    propTypes: {
-      summary: React.PropTypes.string.isRequired
-    },
+  var CallUrlResult = React.createClass({displayName: 'CallUrlResult',
+    mixins: [sharedMixins.DocumentVisibilityMixin],
 
-    render: function() {
-      return (
-        React.DOM.div({className: "share generate-url"}, 
-          React.DOM.div({className: "description"}, this.props.summary), 
-          React.DOM.div({className: "action"}, 
-            this.props.children
-          )
-        )
-      );
-    }
-  });
-
-  var CallUrlResult = React.createClass({displayName: 'CallUrlResult',
     propTypes: {
       callUrl:        React.PropTypes.string,
       callUrlExpiry:  React.PropTypes.number,
-      notifier:       React.PropTypes.object.isRequired,
+      notifications:  React.PropTypes.object.isRequired,
       client:         React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
         pending: false,
         copied: false,
         callUrl: this.props.callUrl || "",
         callUrlExpiry: 0
       };
     },
 
     /**
+     * Provided by DocumentVisibilityMixin. Schedules retrieval of a new call
+     * URL everytime the panel is reopened.
+     */
+    onDocumentVisible: function() {
+      this._fetchCallUrl();
+    },
+
+    /**
     * Returns a random 5 character string used to identify
     * the conversation.
     * XXX this will go away once the backend changes
     */
     conversationIdentifier: function() {
       return Math.random().toString(36).substring(5);
     },
 
     componentDidMount: function() {
       // If we've already got a callURL, don't bother requesting a new one.
       // As of this writing, only used for visual testing in the UI showcase.
       if (this.state.callUrl.length) {
         return;
       }
 
+      this._fetchCallUrl();
+    },
+
+    /**
+     * Fetches a call URL.
+     */
+    _fetchCallUrl: function() {
       this.setState({pending: true});
       this.props.client.requestCallUrl(this.conversationIdentifier(),
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
-      this.props.notifier.clear();
+      this.props.notifications.reset();
 
       if (err) {
-        this.props.notifier.errorL10n("unable_retrieve_url");
+        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();
 
           this.setState({pending: false, copied: false,
                          callUrl: callUrl.href,
                          callUrlExpiry: callUrlData.expiresAt});
         } catch(e) {
           console.log(e);
-          this.props.notifier.errorL10n("unable_retrieve_url");
+          this.props.notifications.errorL10n("unable_retrieve_url");
           this.setState(this.getInitialState());
         }
       }
     },
 
-    _generateMailTo: function() {
-      return encodeURI([
-        "mailto:?subject=" + __("share_email_subject3") + "&",
-        "body=" + __("share_email_body3", {callUrl: this.state.callUrl})
-      ].join(""));
-    },
-
     handleEmailButtonClick: function(event) {
       this.handleLinkExfiltration(event);
-      // Note: side effect
-      document.location = event.target.dataset.mailto;
+
+      navigator.mozLoop.composeEmail(__("share_email_subject3"),
+        __("share_email_body3", { callUrl: this.state.callUrl }));
     },
 
     handleCopyButtonClick: function(event) {
       this.handleLinkExfiltration(event);
       // XXX the mozLoop object should be passed as a prop, to ease testing and
       //     using a fake implementation in UI components showcase.
       navigator.mozLoop.copyString(this.state.callUrl);
       this.setState({copied: true});
@@ -353,183 +363,189 @@ loop.panel = (function(_, mozL10n) {
       // makes it immutable ie read only but that is fine in our case.
       // readOnly attr will suppress a warning regarding this issue
       // from the react lib.
       var cx = React.addons.classSet;
       var inputCSSClass = cx({
         "pending": this.state.pending,
         // Used in functional testing, signals that
         // call url was received from loop server
-         "callUrl": !this.state.pending
+        "callUrl": !this.state.pending
       });
       return (
-        PanelLayout({summary: __("share_link_header_text")}, 
-          React.DOM.div({className: "invite"}, 
-            React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true", 
-                   onCopy: this.handleLinkExfiltration, 
-                   className: inputCSSClass}), 
-            React.DOM.p({className: "btn-group url-actions"}, 
-              React.DOM.button({className: "btn btn-email", disabled: !this.state.callUrl, 
-                onClick: this.handleEmailButtonClick, 
-                'data-mailto': this._generateMailTo()}, 
-                __("share_button")
-              ), 
-              React.DOM.button({className: "btn btn-copy", disabled: !this.state.callUrl, 
-                onClick: this.handleCopyButtonClick}, 
-                this.state.copied ? __("copied_url_button") :
-                                     __("copy_url_button")
-              )
-            )
+        React.DOM.div({className: "generate-url"}, 
+          React.DOM.header(null, __("share_link_header_text")), 
+          React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true", 
+                 onCopy: this.handleLinkExfiltration, 
+                 className: inputCSSClass}), 
+          ButtonGroup({additionalClass: "url-actions"}, 
+            Button({additionalClass: "button-email", 
+                    disabled: !this.state.callUrl, 
+                    onClick: this.handleEmailButtonClick, 
+                    caption: mozL10n.get("share_button")}), 
+            Button({additionalClass: "button-copy", 
+                    disabled: !this.state.callUrl, 
+                    onClick: this.handleCopyButtonClick, 
+                    caption: this.state.copied ? mozL10n.get("copied_url_button") :
+                                                 mozL10n.get("copy_url_button")})
           )
         )
       );
     }
   });
 
   /**
    * FxA sign in/up link component.
    */
   var AuthLink = React.createClass({displayName: 'AuthLink',
     handleSignUpLinkClick: function() {
       navigator.mozLoop.logInToFxA();
     },
 
     render: function() {
-      if (navigator.mozLoop.loggedInToFxA) { // XXX to be implemented
+      if (navigator.mozLoop.userProfile) {
         return null;
       }
       return (
         React.DOM.p({className: "signin-link"}, 
           React.DOM.a({href: "#", onClick: this.handleSignUpLinkClick}, 
             __("panel_footer_signin_or_signup_link")
           )
         )
       );
     }
   });
 
   /**
-   * Panel view.
+   * FxA user identity (guest/authenticated) component.
    */
-  var PanelView = React.createClass({displayName: 'PanelView',
-    propTypes: {
-      notifier: React.PropTypes.object.isRequired,
-      client: React.PropTypes.object.isRequired,
-      // Mostly used for UI components showcase and unit tests
-      callUrl: React.PropTypes.string
-    },
-
+  var UserIdentity = React.createClass({displayName: 'UserIdentity',
     render: function() {
       return (
-        React.DOM.div(null, 
-          CallUrlResult({client: this.props.client, 
-                         notifier: this.props.notifier, 
-                         callUrl: this.props.callUrl}), 
-          ToSView(null), 
-          React.DOM.div({className: "footer"}, 
-            AvailabilityDropdown(null), 
-            AuthLink(null), 
-            SettingsDropdown(null)
-          )
+        React.DOM.p({className: "user-identity"}, 
+          this.props.displayName
         )
       );
     }
   });
 
-  var PanelRouter = loop.desktopRouter.DesktopRouter.extend({
-    /**
-     * DOM document object.
-     * @type {HTMLDocument}
-     */
-    document: undefined,
+  /**
+   * Panel view.
+   */
+  var PanelView = React.createClass({displayName: 'PanelView',
+    propTypes: {
+      notifications: React.PropTypes.object.isRequired,
+      client: React.PropTypes.object.isRequired,
+      // Mostly used for UI components showcase and unit tests
+      callUrl: React.PropTypes.string,
+      userProfile: React.PropTypes.object,
+    },
 
-    routes: {
-      "": "home"
+    getInitialState: function() {
+      return {
+        userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
+      };
     },
 
-    initialize: function(options) {
-      options = options || {};
-      if (!options.document) {
-        throw new Error("missing required document");
-      }
-      this.document = options.document;
+    _onAuthStatusChange: function() {
+      this.setState({userProfile: navigator.mozLoop.userProfile});
+    },
+
+    startForm: function(name, contact) {
+      this.refs[name].initForm(contact);
+      this.selectTab(name);
+    },
 
-      this._registerVisibilityChangeEvent();
+    selectTab: function(name) {
+      this.refs.tabView.setState({ selectedTab: name });
+    },
 
-      this.on("panel:open panel:closed", this.clearNotifications, this);
-      this.on("panel:open", this.reset, this);
+    componentDidMount: function() {
+      window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
+    },
+
+    componentWillUnmount: function() {
+      window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
     },
 
-    /**
-     * Register the DOM visibility API event for the whole document, and trigger
-     * appropriate events accordingly:
-     *
-     * - `panel:opened` when the panel is open
-     * - `panel:closed` when the panel is closed
-     *
-     * @link  http://www.w3.org/TR/page-visibility/
-     */
-    _registerVisibilityChangeEvent: function() {
-      // XXX pass in the visibility status to detect when to generate a new
-      // panel view
-      this.document.addEventListener("visibilitychange", function(event) {
-        this.trigger(event.currentTarget.hidden ? "panel:closed"
-                                                : "panel:open");
-      }.bind(this));
-    },
-
-    /**
-     * Default entry point.
-     */
-    home: function() {
-      this.reset();
-    },
-
-    clearNotifications: function() {
-      this._notifier.clear();
-    },
-
-    /**
-     * Resets this router to its initial state.
-     */
-    reset: function() {
-      this._notifier.clear();
-      var client = new loop.Client({
-        baseServerUrl: navigator.mozLoop.serverUrl
-      });
-      this.loadReactComponent(PanelView({client: client, 
-                                         notifier: this._notifier}));
+    render: function() {
+      var NotificationListView = sharedViews.NotificationListView;
+      var displayName = this.state.userProfile && this.state.userProfile.email ||
+                        __("display_name_guest");
+      return (
+        React.DOM.div(null, 
+          NotificationListView({notifications: this.props.notifications, 
+                                clearOnDocumentHidden: true}), 
+          TabView({ref: "tabView"}, 
+            Tab({name: "call"}, 
+              React.DOM.div({className: "content-area"}, 
+                CallUrlResult({client: this.props.client, 
+                               notifications: this.props.notifications, 
+                               callUrl: this.props.callUrl}), 
+                ToSView(null)
+              )
+            ), 
+            Tab({name: "contacts"}, 
+              ContactsList({selectTab: this.selectTab, 
+                            startForm: this.startForm})
+            ), 
+            Tab({name: "contacts_add", hidden: true}, 
+              ContactDetailsForm({ref: "contacts_add", mode: "add", 
+                                  selectTab: this.selectTab})
+            ), 
+            Tab({name: "contacts_edit", hidden: true}, 
+              ContactDetailsForm({ref: "contacts_edit", mode: "edit", 
+                                  selectTab: this.selectTab})
+            ), 
+            Tab({name: "contacts_import", hidden: true}, 
+              ContactDetailsForm({ref: "contacts_import", mode: "import", 
+                                  selectTab: this.selectTab})
+            )
+          ), 
+          React.DOM.div({className: "footer"}, 
+            React.DOM.div({className: "user-details"}, 
+              UserIdentity({displayName: displayName}), 
+              AvailabilityDropdown(null)
+            ), 
+            AuthLink(null), 
+            SettingsDropdown(null)
+          )
+        )
+      );
     }
   });
 
   /**
    * Panel 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);
 
-    router = new PanelRouter({
-      document: document,
-      notifier: new sharedViews.NotificationListView({el: "#messages"})
-    });
-    Backbone.history.start();
+    var client = new loop.Client();
+    var notifications = new sharedModels.NotificationCollection()
+
+    React.renderComponent(PanelView({
+      client: client, 
+      notifications: notifications}), document.querySelector("#main"));
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
     document.body.setAttribute("dir", mozL10n.getDirection());
 
     // Notify the window that we've finished initalization and initial layout
     var evtObject = document.createEvent('Event');
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
+    UserIdentity: UserIdentity,
     AvailabilityDropdown: AvailabilityDropdown,
     CallUrlResult: CallUrlResult,
     PanelView: PanelView,
-    PanelRouter: PanelRouter,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView
   };
 })(_, document.mozL10n);
+
+document.addEventListener('DOMContentLoaded', loop.panel.init);
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -6,61 +6,78 @@
 
 /*jshint newcap:false*/
 /*global loop:true, React */
 
 var loop = loop || {};
 loop.panel = (function(_, mozL10n) {
   "use strict";
 
-  var sharedViews = loop.shared.views,
-      // aliasing translation function as __ for concision
-      __ = mozL10n.get;
+  var sharedViews = loop.shared.views;
+  var sharedModels = loop.shared.models;
+  var sharedMixins = loop.shared.mixins;
+  var Button = sharedViews.Button;
+  var ButtonGroup = sharedViews.ButtonGroup;
+  var ContactsList = loop.contacts.ContactsList;
+  var ContactDetailsForm = loop.contacts.ContactDetailsForm;
+  var __ = mozL10n.get; // aliasing translation function as __ for concision
 
-  /**
-   * Panel router.
-   * @type {loop.desktopRouter.DesktopRouter}
-   */
-  var router;
+  var TabView = React.createClass({
+    getInitialState: function() {
+      return {
+        selectedTab: "call"
+      };
+    },
 
-  /**
-   * Dropdown menu mixin.
-   * @type {Object}
-   */
-  var DropdownMenuMixin = {
-    getInitialState: function() {
-      return {showMenu: false};
+    handleSelectTab: function(event) {
+      var tabName = event.target.dataset.tabName;
+      this.setState({selectedTab: tabName});
     },
 
-    _onBodyClick: function() {
-      this.setState({showMenu: false});
-    },
-
-    componentDidMount: function() {
-      document.body.addEventListener("click", this._onBodyClick);
-    },
+    render: function() {
+      var cx = React.addons.classSet;
+      var tabButtons = [];
+      var tabs = [];
+      React.Children.forEach(this.props.children, function(tab, i) {
+        var tabName = tab.props.name;
+        var isSelected = (this.state.selectedTab == tabName);
+        if (!tab.props.hidden) {
+          tabButtons.push(
+            <li className={cx({selected: isSelected})}
+                key={i}
+                data-tab-name={tabName}
+                onClick={this.handleSelectTab} />
+          );
+        }
+        tabs.push(
+          <div key={i} className={cx({tab: true, selected: isSelected})}>
+            {tab.props.children}
+          </div>
+        );
+      }, this);
+      return (
+        <div className="tab-view-container">
+          <ul className="tab-view">{tabButtons}</ul>
+          {tabs}
+        </div>
+      );
+    }
+  });
 
-    componentWillUnmount: function() {
-      document.body.removeEventListener("click", this._onBodyClick);
-    },
-
-    showDropdownMenu: function() {
-      this.setState({showMenu: true});
-    },
-
-    hideDropdownMenu: function() {
-      this.setState({showMenu: false});
+  var Tab = React.createClass({
+    render: function() {
+      return null;
     }
-  };
+  });
 
   /**
    * Availability drop down menu subview.
    */
   var AvailabilityDropdown = React.createClass({
-    mixins: [DropdownMenuMixin],
+    mixins: [sharedMixins.DropdownMenuMixin],
 
     getInitialState: function() {
       return {
         doNotDisturb: navigator.mozLoop.doNotDisturb
       };
     },
 
     // XXX target event can either be the li, the span or the i tag
@@ -182,50 +199,49 @@ loop.panel = (function(_, mozL10n) {
       );
     }
   });
 
   /**
    * Panel settings (gear) menu.
    */
   var SettingsDropdown = React.createClass({
-    mixins: [DropdownMenuMixin],
+    mixins: [sharedMixins.DropdownMenuMixin],
 
     handleClickSettingsEntry: function() {
-      // XXX to be implemented
+      // XXX to be implemented at the same time as unhiding the entry
     },
 
     handleClickAccountEntry: function() {
-      // XXX to be implemented
+      navigator.mozLoop.openFxASettings();
     },
 
     handleClickAuthEntry: function() {
       if (this._isSignedIn()) {
-        // XXX to be implemented - bug 979845
         navigator.mozLoop.logOutFromFxA();
       } else {
         navigator.mozLoop.logInToFxA();
       }
     },
 
     _isSignedIn: function() {
-      // XXX to be implemented - bug 979845
-      return !!navigator.mozLoop.loggedInToFxA;
+      return !!navigator.mozLoop.userProfile;
     },
 
     render: function() {
       var cx = React.addons.classSet;
       return (
         <div className="settings-menu dropdown">
-          <a className="btn btn-settings" onClick={this.showDropdownMenu}
+          <a className="button-settings" onClick={this.showDropdownMenu}
              title={__("settings_menu_button_tooltip")} />
           <ul className={cx({"dropdown-menu": true, hide: !this.state.showMenu})}
               onMouseLeave={this.hideDropdownMenu}>
             <SettingsDropdownEntry label={__("settings_menu_item_settings")}
                                    onClick={this.handleClickSettingsEntry}
+                                   displayed={false}
                                    icon="settings" />
             <SettingsDropdownEntry label={__("settings_menu_item_account")}
                                    onClick={this.handleClickAccountEntry}
                                    icon="account"
                                    displayed={this._isSignedIn()} />
             <SettingsDropdownEntry label={this._isSignedIn() ?
                                           __("settings_menu_item_signout") :
                                           __("settings_menu_item_signin")}
@@ -233,109 +249,103 @@ loop.panel = (function(_, mozL10n) {
                                    icon={this._isSignedIn() ? "signout" : "signin"} />
           </ul>
         </div>
       );
     }
   });
 
   /**
-   * Panel layout.
+   * Call url result view.
    */
-  var PanelLayout = React.createClass({
-    propTypes: {
-      summary: React.PropTypes.string.isRequired
-    },
+  var CallUrlResult = React.createClass({
+    mixins: [sharedMixins.DocumentVisibilityMixin],
 
-    render: function() {
-      return (
-        <div className="share generate-url">
-          <div className="description">{this.props.summary}</div>
-          <div className="action">
-            {this.props.children}
-          </div>
-        </div>
-      );
-    }
-  });
-
-  var CallUrlResult = React.createClass({
     propTypes: {
       callUrl:        React.PropTypes.string,
       callUrlExpiry:  React.PropTypes.number,
-      notifier:       React.PropTypes.object.isRequired,
+      notifications:  React.PropTypes.object.isRequired,
       client:         React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
         pending: false,
         copied: false,
         callUrl: this.props.callUrl || "",
         callUrlExpiry: 0
       };
     },
 
     /**
+     * Provided by DocumentVisibilityMixin. Schedules retrieval of a new call
+     * URL everytime the panel is reopened.
+     */
+    onDocumentVisible: function() {
+      this._fetchCallUrl();
+    },
+
+    /**
     * Returns a random 5 character string used to identify
     * the conversation.
     * XXX this will go away once the backend changes
     */
     conversationIdentifier: function() {
       return Math.random().toString(36).substring(5);
     },
 
     componentDidMount: function() {
       // If we've already got a callURL, don't bother requesting a new one.
       // As of this writing, only used for visual testing in the UI showcase.
       if (this.state.callUrl.length) {
         return;
       }
 
+      this._fetchCallUrl();
+    },
+
+    /**
+     * Fetches a call URL.
+     */
+    _fetchCallUrl: function() {
       this.setState({pending: true});
       this.props.client.requestCallUrl(this.conversationIdentifier(),
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
-      this.props.notifier.clear();
+      this.props.notifications.reset();
 
       if (err) {
-        this.props.notifier.errorL10n("unable_retrieve_url");
+        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();
 
           this.setState({pending: false, copied: false,
                          callUrl: callUrl.href,
                          callUrlExpiry: callUrlData.expiresAt});
         } catch(e) {
           console.log(e);
-          this.props.notifier.errorL10n("unable_retrieve_url");
+          this.props.notifications.errorL10n("unable_retrieve_url");
           this.setState(this.getInitialState());
         }
       }
     },
 
-    _generateMailTo: function() {
-      return encodeURI([
-        "mailto:?subject=" + __("share_email_subject3") + "&",
-        "body=" + __("share_email_body3", {callUrl: this.state.callUrl})
-      ].join(""));
-    },
-
     handleEmailButtonClick: function(event) {
       this.handleLinkExfiltration(event);
-      // Note: side effect
-      document.location = event.target.dataset.mailto;
+
+      navigator.mozLoop.composeEmail(__("share_email_subject3"),
+        __("share_email_body3", { callUrl: this.state.callUrl }));
     },
 
     handleCopyButtonClick: function(event) {
       this.handleLinkExfiltration(event);
       // XXX the mozLoop object should be passed as a prop, to ease testing and
       //     using a fake implementation in UI components showcase.
       navigator.mozLoop.copyString(this.state.callUrl);
       this.setState({copied: true});
@@ -353,183 +363,189 @@ loop.panel = (function(_, mozL10n) {
       // makes it immutable ie read only but that is fine in our case.
       // readOnly attr will suppress a warning regarding this issue
       // from the react lib.
       var cx = React.addons.classSet;
       var inputCSSClass = cx({
         "pending": this.state.pending,
         // Used in functional testing, signals that
         // call url was received from loop server
-         "callUrl": !this.state.pending
+        "callUrl": !this.state.pending
       });
       return (
-        <PanelLayout summary={__("share_link_header_text")}>
-          <div className="invite">
-            <input type="url" value={this.state.callUrl} readOnly="true"
-                   onCopy={this.handleLinkExfiltration}
-                   className={inputCSSClass} />
-            <p className="btn-group url-actions">
-              <button className="btn btn-email" disabled={!this.state.callUrl}
-                onClick={this.handleEmailButtonClick}
-                data-mailto={this._generateMailTo()}>
-                {__("share_button")}
-              </button>
-              <button className="btn btn-copy" disabled={!this.state.callUrl}
-                onClick={this.handleCopyButtonClick}>
-                {this.state.copied ? __("copied_url_button") :
-                                     __("copy_url_button")}
-              </button>
-            </p>
-          </div>
-        </PanelLayout>
+        <div className="generate-url">
+          <header>{__("share_link_header_text")}</header>
+          <input type="url" value={this.state.callUrl} readOnly="true"
+                 onCopy={this.handleLinkExfiltration}
+                 className={inputCSSClass} />
+          <ButtonGroup additionalClass="url-actions">
+            <Button additionalClass="button-email"
+                    disabled={!this.state.callUrl}
+                    onClick={this.handleEmailButtonClick}
+                    caption={mozL10n.get("share_button")} />
+            <Button additionalClass="button-copy"
+                    disabled={!this.state.callUrl}
+                    onClick={this.handleCopyButtonClick}
+                    caption={this.state.copied ? mozL10n.get("copied_url_button") :
+                                                 mozL10n.get("copy_url_button")} />
+          </ButtonGroup>
+        </div>
       );
     }
   });
 
   /**
    * FxA sign in/up link component.
    */
   var AuthLink = React.createClass({
     handleSignUpLinkClick: function() {
       navigator.mozLoop.logInToFxA();
     },
 
     render: function() {
-      if (navigator.mozLoop.loggedInToFxA) { // XXX to be implemented
+      if (navigator.mozLoop.userProfile) {
         return null;
       }
       return (
         <p className="signin-link">
           <a href="#" onClick={this.handleSignUpLinkClick}>
             {__("panel_footer_signin_or_signup_link")}
           </a>
         </p>
       );
     }
   });
 
   /**
+   * FxA user identity (guest/authenticated) component.
+   */
+  var UserIdentity = React.createClass({
+    render: function() {
+      return (
+        <p className="user-identity">
+          {this.props.displayName}
+        </p>
+      );
+    }
+  });
+
+  /**
    * Panel view.
    */
   var PanelView = React.createClass({
     propTypes: {
-      notifier: React.PropTypes.object.isRequired,
+      notifications: React.PropTypes.object.isRequired,
       client: React.PropTypes.object.isRequired,
       // Mostly used for UI components showcase and unit tests
-      callUrl: React.PropTypes.string
+      callUrl: React.PropTypes.string,
+      userProfile: React.PropTypes.object,
+    },
+
+    getInitialState: function() {
+      return {
+        userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
+      };
+    },
+
+    _onAuthStatusChange: function() {
+      this.setState({userProfile: navigator.mozLoop.userProfile});
+    },
+
+    startForm: function(name, contact) {
+      this.refs[name].initForm(contact);
+      this.selectTab(name);
+    },
+
+    selectTab: function(name) {
+      this.refs.tabView.setState({ selectedTab: name });
+    },
+
+    componentDidMount: function() {
+      window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
+    },
+
+    componentWillUnmount: function() {
+      window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
     },
 
     render: function() {
+      var NotificationListView = sharedViews.NotificationListView;
+      var displayName = this.state.userProfile && this.state.userProfile.email ||
+                        __("display_name_guest");
       return (
         <div>
-          <CallUrlResult client={this.props.client}
-                         notifier={this.props.notifier}
-                         callUrl={this.props.callUrl} />
-          <ToSView />
+          <NotificationListView notifications={this.props.notifications}
+                                clearOnDocumentHidden={true} />
+          <TabView ref="tabView">
+            <Tab name="call">
+              <div className="content-area">
+                <CallUrlResult client={this.props.client}
+                               notifications={this.props.notifications}
+                               callUrl={this.props.callUrl} />
+                <ToSView />
+              </div>
+            </Tab>
+            <Tab name="contacts">
+              <ContactsList selectTab={this.selectTab}
+                            startForm={this.startForm} />
+            </Tab>
+            <Tab name="contacts_add" hidden={true}>
+              <ContactDetailsForm ref="contacts_add" mode="add"
+                                  selectTab={this.selectTab} />
+            </Tab>
+            <Tab name="contacts_edit" hidden={true}>
+              <ContactDetailsForm ref="contacts_edit" mode="edit"
+                                  selectTab={this.selectTab} />
+            </Tab>
+            <Tab name="contacts_import" hidden={true}>
+              <ContactDetailsForm ref="contacts_import" mode="import"
+                                  selectTab={this.selectTab}/>
+            </Tab>
+          </TabView>
           <div className="footer">
-            <AvailabilityDropdown />
+            <div className="user-details">
+              <UserIdentity displayName={displayName} />
+              <AvailabilityDropdown />
+            </div>
             <AuthLink />
             <SettingsDropdown />
           </div>
         </div>
       );
     }
   });
 
-  var PanelRouter = loop.desktopRouter.DesktopRouter.extend({
-    /**
-     * DOM document object.
-     * @type {HTMLDocument}
-     */
-    document: undefined,
-
-    routes: {
-      "": "home"
-    },
-
-    initialize: function(options) {
-      options = options || {};
-      if (!options.document) {
-        throw new Error("missing required document");
-      }
-      this.document = options.document;
-
-      this._registerVisibilityChangeEvent();
-
-      this.on("panel:open panel:closed", this.clearNotifications, this);
-      this.on("panel:open", this.reset, this);
-    },
-
-    /**
-     * Register the DOM visibility API event for the whole document, and trigger
-     * appropriate events accordingly:
-     *
-     * - `panel:opened` when the panel is open
-     * - `panel:closed` when the panel is closed
-     *
-     * @link  http://www.w3.org/TR/page-visibility/
-     */
-    _registerVisibilityChangeEvent: function() {
-      // XXX pass in the visibility status to detect when to generate a new
-      // panel view
-      this.document.addEventListener("visibilitychange", function(event) {
-        this.trigger(event.currentTarget.hidden ? "panel:closed"
-                                                : "panel:open");
-      }.bind(this));
-    },
-
-    /**
-     * Default entry point.
-     */
-    home: function() {
-      this.reset();
-    },
-
-    clearNotifications: function() {
-      this._notifier.clear();
-    },
-
-    /**
-     * Resets this router to its initial state.
-     */
-    reset: function() {
-      this._notifier.clear();
-      var client = new loop.Client({
-        baseServerUrl: navigator.mozLoop.serverUrl
-      });
-      this.loadReactComponent(<PanelView client={client}
-                                         notifier={this._notifier} />);
-    }
-  });
-
   /**
    * Panel 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);
 
-    router = new PanelRouter({
-      document: document,
-      notifier: new sharedViews.NotificationListView({el: "#messages"})
-    });
-    Backbone.history.start();
+    var client = new loop.Client();
+    var notifications = new sharedModels.NotificationCollection()
+
+    React.renderComponent(<PanelView
+      client={client}
+      notifications={notifications} />, document.querySelector("#main"));
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
     document.body.setAttribute("dir", mozL10n.getDirection());
 
     // Notify the window that we've finished initalization and initial layout
     var evtObject = document.createEvent('Event');
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
+    UserIdentity: UserIdentity,
     AvailabilityDropdown: AvailabilityDropdown,
     CallUrlResult: CallUrlResult,
     PanelView: PanelView,
-    PanelRouter: PanelRouter,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView
   };
 })(_, document.mozL10n);
+
+document.addEventListener('DOMContentLoaded', loop.panel.init);
--- a/browser/components/loop/content/panel.html
+++ b/browser/components/loop/content/panel.html
@@ -4,30 +4,29 @@
    - file, You can obtain one at http://mozilla.org/MPL/2.0/.  -->
 <html>
   <head>
     <meta charset="utf-8">
     <title>Loop Panel</title>
     <link rel="stylesheet" type="text/css" href="loop/shared/css/reset.css">
     <link rel="stylesheet" type="text/css" href="loop/shared/css/common.css">
     <link rel="stylesheet" type="text/css" href="loop/shared/css/panel.css">
+    <link rel="stylesheet" type="text/css" href="loop/shared/css/contacts.css">
   </head>
-  <body class="panel" onload="loop.panel.init();">
-
-    <div id="messages"></div>
+  <body class="panel">
 
     <div id="main"></div>
 
     <script type="text/javascript" src="loop/shared/libs/react-0.11.1.js"></script>
     <script type="text/javascript" src="loop/libs/l10n.js"></script>
     <script type="text/javascript" src="loop/shared/libs/jquery-2.1.0.js"></script>
     <script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
 
     <script type="text/javascript" src="loop/shared/js/utils.js"></script>
     <script type="text/javascript" src="loop/shared/js/models.js"></script>
-    <script type="text/javascript" src="loop/shared/js/router.js"></script>
+    <script type="text/javascript" src="loop/shared/js/mixins.js"></script>
     <script type="text/javascript" src="loop/shared/js/views.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
-    <script type="text/javascript" src="loop/js/desktopRouter.js"></script>
+    <script type="text/javascript;version=1.8" src="loop/js/contacts.js"></script>
     <script type="text/javascript" src="loop/js/panel.js"></script>
  </body>
 </html>
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -13,29 +13,21 @@
 }
 
 body {
   font-family: "Lucida Grande", sans-serif;
   font-size: 12px;
   background: #fbfbfb;
 }
 
-button {
-  /* Resetting default <button> font properties; eg. strangely enough, FF mac
-     wants to use Helvetica/12px whatever we define for parent elements */
-  font-family: "Lucida Grande", sans-serif;
-  font-size: 1em;
-}
-
 img {
   border: none;
 }
 
 h1, h2, h3 {
-  font-family: "Open Sans", sans-serif;
   color: #666;
 }
 
 /* choose a sane default for paragraphs, since reset.css' 0px is not what we want */
 p {
   margin: 1em 0;
 }
 
@@ -138,30 +130,33 @@ p {
     background-color: #64a43a;
     border: 1px solid #64a43a;
   }
 
 .btn-warning {
   background-color: #f0ad4e;
 }
 
+.btn-cancel,
 .btn-error,
 .btn-hangup,
 .btn-error + .btn-chevron {
   background-color: #d74345;
   border: 1px solid #d74345;
 }
 
+  .btn-cancel:hover,
   .btn-error:hover,
   .btn-hangup:hover,
   .btn-error + .btn-chevron:hover {
     background-color: #c53436;
     border: 1px solid #c53436;
   }
 
+  .btn-cancel:active,
   .btn-error:active,
   .btn-hangup:active,
   .btn-error + .btn-chevron:active {
     background-color: #ae2325;
     border: 1px solid #ae2325;
   }
 
 .btn-chevron {
@@ -215,48 +210,50 @@ p {
 }
 
 .btn-group {
   display: flex;
   align-content: space-between;
   justify-content: center;
 }
 
-.btn-group .btn {
+.btn-chevron-menu-group .btn {
   flex: 1;
+  border-radius: 2px;
   border-bottom-right-radius: 0;
   border-top-right-radius: 0;
 }
 
 /* Alerts */
 .alert {
   background: #eee;
-  padding: .2em 1em;
+  padding: .4em 1em;
   margin-bottom: 1em;
+  border-bottom: 2px solid #E9E9E9;
 }
 
 .alert p.message {
   padding: 0;
   margin: 0;
 }
 
-.alert.alert-error {
-  background: #f99;
-  border: 1px solid #f77;
+.alert-error {
+  background: repeating-linear-gradient(-45deg, #D74345, #D74345 10px, #D94B4D 10px, #D94B4D 20px) repeat scroll 0% 0% transparent;
+  color: #fff;
 }
 
-.alert.alert-warning {
+.alert-warning {
   background: #fcf8e3;
   border: 1px solid #fbeed5;
 }
 
 .alert .close {
   position: relative;
-  top: -.2em;
-  right: -1em;
+  top: -.1rem;
+  right: -1rem;
 }
 
 /* Misc */
 
 .call-url,
 .overflow-text-ellipsis,
 .standalone-call-btn-text,
 .fx-embedded-answer-btn-text {
@@ -264,34 +261,35 @@ p {
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
 
 
 .close {
   float: right;
-  font-size: 20px;
+  font-size: 1rem;
   font-weight: bold;
-  line-height: 1em;
+  line-height: 1rem;
   color: #000;
-  opacity: .2;
+  opacity: .4;
+  background: none;
+  border: none;
+  cursor: pointer;
 }
 
+  .close:hover {
+    opacity: .6;
+  }
+
 .close:before {
   /* \2716 is unicode representation of the close button icon */
   content: '\2716';
 }
 
-.btn.close {
-  background: none;
-  border: none;
-  cursor: pointer;
-}
-
 /* Transitions */
 .fade-out {
   transition: opacity 0.5s ease-in;
   opacity: 0;
 }
 
 .icon,
 .icon-small,
@@ -341,25 +339,29 @@ p {
 }
 
 .mac p,
 .windows p,
 .linux p {
   line-height: 16px;
 }
 
-.windows {
+/* Using star selector to override
+ * the specificity of other selectors
+ * if performance is an issue we could
+ * explicitely list all the elements */
+.windows * {
   font-family: 'Segoe';
 }
 
-.mac {
+.mac * {
   font-family: 'Lucida Grande';
 }
 
-.linux {
+.linux * {
   /* XXX requires fallbacks */
   font-family: 'Ubuntu', sans-serif;
 }
 
 /* Web panel */
 
 .info-panel {
   border-radius: 4px;
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/css/contacts.css
@@ -0,0 +1,151 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.contact-list {
+  border-top: 1px solid #ccc;
+  overflow-x: hidden;
+  overflow-y: auto;
+  /* Show six contacts and scroll for the rest */
+  max-height: 305px;
+}
+
+.contact,
+.contact-separator {
+  padding: 5px 10px;
+  font-size: 13px;
+}
+
+.contact {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  color: #666;
+}
+
+.contact-separator {
+  height: 24px;
+  background: #eee;
+  color: #888;
+}
+
+.contact:not(:first-child) {
+  border-top: 1px solid #ddd;
+}
+
+.contact-separator:not(:first-child) {
+  border-top: 1px solid #ccc;
+}
+
+.contact:hover {
+  background: #eee;
+}
+
+.contact:hover > .icons {
+  display: block;
+  z-index: 1000;
+}
+
+.contact > .avatar {
+  width: 40px;
+  height: 40px;
+  background: #ccc;
+  border-radius: 50%;
+  margin-right: 10px;
+  overflow: hidden;
+  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
+  background-image: url("../img/audio-call-avatar.svg");
+  background-repeat: no-repeat;
+  background-color: #4ba6e7;
+  background-size: contain;
+  -moz-user-select: none;
+}
+
+.contact > .avatar > img {
+  width: 100%;
+}
+
+.contact > .details > .username {
+  font-size: 12px;
+  line-height: 20px;
+  color: #222;
+}
+
+.contact.blocked > .details > .username {
+  color: #d74345;
+}
+
+.contact > .details > .username > strong {
+  font-weight: bold;
+}
+
+.contact > .details > .username > i.icon-blocked {
+  display: inline-block;
+  width: 10px;
+  height: 20px;
+  -moz-margin-start: 3px;
+  background-image: url("../img/icons-16x16.svg#block-red");
+  background-position: center;
+  background-size: 10px 10px;
+  background-repeat: no-repeat;
+}
+
+.contact > .details > .username > i.icon-google {
+  position: absolute;
+  right: 10px;
+  top: 35%;
+  width: 14px;
+  height: 14px;
+  border-radius: 50%;
+  background-image: url("../img/icons-16x16.svg#google");
+  background-position: center;
+  background-size: 16px 16px;
+  background-repeat: no-repeat;
+  background-color: fff;
+}
+
+.contact > .details > .email {
+  color: #999;
+  font-size: 11px;
+  line-height: 16px;
+}
+
+.icons {
+  cursor: pointer;
+  display: none;
+  margin-left: auto;
+  padding: 12px 10px;
+  border-radius: 30px;
+  background: #7ed321;
+  -moz-user-select: none;
+}
+
+.icons:hover {
+  background: #89e029;
+}
+
+.icons i {
+  margin: 0 5px;
+  display: inline-block;
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+.icons i.icon-video {
+  background-image: url("../img/icons-14x14.svg#video-white");
+  background-size: 14px 14px;
+  width: 16px;
+  height: 16px;
+}
+
+.icons i.icon-caret-down {
+  background-image: url("../img/icons-10x10.svg#dropdown-white");
+  background-size: 10px 10px;
+  width: 10px;
+  height: 16px;
+}
+
+.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
@@ -1,52 +1,41 @@
 /* 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/. */
 
 /* Shared conversation window styles */
-.standalone .video-layout-wrapper {
+.standalone .video-layout-wrapper,
+.conversation .media video {
   background-color: #444;
 }
 
 .conversation {
   position: relative;
 }
 
-.standalone .conversation {
-  margin: 0 auto;
-  max-height: 100vh;
-}
-
 .conversation-toolbar {
   z-index: 999; /* required to have it superimposed to the video element */
   border: 1px solid #5a5a5a;
   border-left: 0;
   border-right: 0;
-}
-
-.conversation .conversation-toolbar {
-  position: absolute;
-  left: 0;
-  right: 0;
+  background: rgba(0,0,0,.70);
 }
 
 /* desktop version */
-.conversation-window .conversation-toolbar {
+.fx-embedded .conversation-toolbar {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
   height: 26px;
-  background: rgba(0,0,0,.70);
 }
 
 /* standalone version */
 .standalone .conversation-toolbar {
-  bottom: 0;
-}
-
-.standalone .conversation-toolbar {
-  background: rgba(0,0,0,.50);
   padding: 20px;
   height: 64px;
 }
 
 .conversation-toolbar li {
   float: left;
   font-size: 0; /* prevents vertical bottom padding added to buttons in google
                    chrome */
@@ -89,38 +78,76 @@
   }
 
 .fx-embedded-answer-btn-text {
   vertical-align: bottom;
   /* don't stretch the button if the localized text is too big */
   max-width: 80%;
 }
 
-.fx-embedded-btn-icon-video {
+.fx-embedded-btn-icon-video,
+.fx-embedded-btn-icon-audio {
   display: inline-block;
   vertical-align: top;
   width: .8rem;
   height: .8rem;
-  background-image: url("../img/video-inverse-14x14.png");
   background-repeat: no-repeat;
   cursor: pointer;
 }
 
+.fx-embedded-btn-icon-video,
+.fx-embedded-btn-video-small {
+  background-image: url("../img/video-inverse-14x14.png");
+}
+
+.fx-embedded-btn-icon-audio,
+.fx-embedded-btn-audio-small {
+  background-image: url("../img/audio-inverse-14x14.png");
+}
+
+.fx-embedded-btn-audio-small,
+.fx-embedded-btn-video-small {
+  width: 26px;
+  height: 26px;
+  border-left: 1px solid rgba(255,255,255,.4);
+  border-top-right-radius: 2px;
+  border-bottom-right-radius: 2px;
+  background-color: #74BF43;
+  background-position: center;
+  background-size: 1rem;
+  background-repeat: no-repeat;
+  cursor: pointer;
+}
+
+  .fx-embedded-btn-video-small:hover,
+  .fx-embedded-btn-audio-small:hover {
+    background-color: #6cb23e;
+  }
+
+@media (min-resolution: 2dppx) {
+  .fx-embedded-btn-audio-small {
+    background-image: url("../img/audio-inverse-14x14@2x.png");
+  }
+  .fx-embedded-btn-video-small {
+    background-image: url("../img/video-inverse-14x14@2x.png");
+  }
+}
+
 .standalone .btn-hangup {
   width: auto;
   font-size: 12px;
   border-radius: 2px;
   padding: 0 20px;
 }
 
-.conversation-window .conversation-toolbar .btn-hangup {
+.fx-embedded .conversation-toolbar .btn-hangup {
   background-image: url(../img/hangup-inverse-14x14.png);
 }
 @media (min-resolution: 2dppx) {
-  .conversation-window .conversation-toolbar .btn-hangup {
+  .fx-embedded .conversation-toolbar .btn-hangup {
     background-image: url(../img/hangup-inverse-14x14@2x.png);
   }
 }
 
 /* Common media control buttons behavior */
 .conversation-toolbar .media-control {
   background-color: transparent;
   opacity: 1;
@@ -161,75 +188,38 @@
   .btn-mute-video {
     background-image: url(../img/video-inverse-14x14@2x.png);
   }
   .btn-mute-video.muted {
     background-image: url(../img/facemute-14x14@2x.png);
   }
 }
 
-/* Video elements */
-
-.conversation .media video {
-  background: #eee;
-}
-
-/* Nested video elements */
-
-.conversation .media.nested {
-  position: relative;
-  text-align: center;
-}
-
-/* fluid aspect ratio trick, see http://stackoverflow.com/a/10441480/330911 */
-.conversation .media.nested .remote_wrapper {
-  display: inline-block;
-  position: relative;
-  width: 100%;
-  padding-bottom: 75%; /* XXX forced 4:3 ratio, see bug 1020445 */
+.fx-embedded .remote_wrapper {
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  bottom: 0px;
+  left: 0px;
 }
 
-.conversation .media.nested .remote {
-  display: inline-block;
-  position: absolute; /* workaround for lack of object-fit; see bug 1020445 */
-  width: 100%;
-  top: 0;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  background: #000;
-}
-
-.conversation .media.nested .local {
-  position: absolute;
-  bottom: 4px;
-  right: 0;
-  width: 30%;
-  max-width: 140px;
-  /* next two lines are workaround for lack of object-fit; see bug 1020445 */
-  height: 22.5%;
-  max-height: 105px;
-}
-
-.standalone .conversation .media.nested .local {
-  /* required to have it superimposed on the control toolbar */
+.standalone .local-stream {
+  /* required to have it superimposed to the control toolbar */
   z-index: 1001;
-  bottom: 10px;
-  right: 10px;
   box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
 }
 
 /* Side by side video elements */
 
 .conversation .media.side-by-side .remote {
   width: 50%;
   float: left;
 }
 
-.conversation .media.side-by-side .local {
+.conversation .media.side-by-side .local-stream {
   width: 50%;
 }
 
 /* Call ended view */
 .call-ended p {
   text-align: center;
 }
 
@@ -240,34 +230,33 @@
  * but the UI breaks when you pop out
  * Bug 1040985
  */
 .incoming-call {
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: space-between;
-  min-height: 264px;
+  min-height: 230px;
 }
 
 .incoming-call-action-group {
   display: flex;
-  padding: 2.5em 0;
+  padding: 2.5em 0 0 0;
   width: 100%;
   justify-content: space-around;
 }
 
 .incoming-call-action-group > .btn {
   margin-left: .5em;
 }
 
 .incoming-call-action-group .btn-group-chevron,
 .incoming-call-action-group .btn-group {
   width: 100%;
-  max-width: 120px;  /* required by the UI Showcase, but the not real code */
 }
 
 /* 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 {
   font-size: 1.5em;
   font-weight: normal;
@@ -277,40 +266,16 @@
   margin: 0.83em 0;
 }
 
 .fx-embedded-incoming-call-button-spacer {
   display: flex;
   flex: 1;
 }
 
-.call-audio-only {
-  width: 26px;
-  height: 26px;
-  border-left: 1px solid rgba(255,255,255,.4);
-  border-top-right-radius: 2px;
-  border-bottom-right-radius: 2px;
-  background-color: #74BF43;
-  background-image: url("../img/audio-inverse-14x14.png");
-  background-size: 1rem;
-  background-position: center;
-  background-repeat: no-repeat;
-  cursor: pointer;
-}
-
-  .call-audio-only:hover {
-    background-color: #6cb23e;
-  }
-
-@media (min-resolution: 2dppx) {
-  .call-audio-only {
-    background-image: url("../img/audio-inverse-14x14@2x.png");
-  }
-}
-
 /* Expired call url page */
 
 .expired-url-info {
   width: 400px;
   margin: 0 auto;
 }
 
 .promote-firefox {
@@ -393,23 +358,26 @@
 }
 
 /* Feedback form */
 
 .feedback {
   padding: 14px;
 }
 
+.feedback p {
+  margin: 0px;
+}
+
 .feedback h3 {
   color: #666;
   font-size: 12px;
   font-weight: 700;
   text-align: center;
-  margin-bottom: 14px;
-  margin-top: 14px;
+  margin: 0 0 1em 0;
 }
 
 .feedback .faces {
   display: flex;
   flex-direction: row;
   align-items: center;
   justify-content: center;
   padding: 20px 0;
@@ -437,25 +405,24 @@
 .feedback .face.face-happy {
   background-image: url("../img/happy.png");
 }
 
 .feedback .face.face-sad {
   background-image: url("../img/sad.png");
 }
 
-.feedback button.back {
+.fx-embedded-btn-back {
+  margin-bottom: 1rem;
+  padding: .2rem .8rem;
+  border: 1px solid #aaa;
   border-radius: 2px;
-  border: 1px solid #CCC;
-  color: #CCC;
-  font-size: 11px;
+  background: transparent;
+  color: #777;
   cursor: pointer;
-  padding: 3px 10px;
-  display: inline;
-  margin-bottom: 14px;
 }
 
 .feedback label {
   display: block;
   line-height: 1.5em;
 }
 
 .feedback form input[type="radio"] {
@@ -469,8 +436,156 @@
 }
 
 .feedback .info {
   display: block;
   font-size: 10px;
   color: #CCC;
   text-align: center;
 }
+
+.fx-embedded .local-stream {
+  position: absolute;
+  right: 3px;
+  bottom: 5px;
+  /* next two lines are workaround for lack of object-fit; see bug 1020445 */
+  max-width: 140px;
+  width: 30%;
+  height: 28%;
+  max-height: 105px;
+  box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.5);
+}
+
+.conversation .media.nested .remote {
+  display: inline-block;
+  position: absolute; /* workaround for lack of object-fit; see bug 1020445 */
+  width: 100%;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+}
+
+/*
+ * XXX this approach is fragile because it makes assumptions
+ * about the generated OT markup, any change will break it
+ */
+.local-stream.local-stream-audio,
+.standalone .OT_subscriber .OT_video-poster,
+.fx-embedded .OT_video-container .OT_video-poster,
+.local-stream-audio .OT_publisher .OT_video-poster {
+  background-image: url("../img/audio-call-avatar.svg");
+  background-repeat: no-repeat;
+  background-color: #4BA6E7;
+  background-size: contain;
+  background-position: center;
+}
+
+.fx-embedded .media.nested {
+  min-height: 200px;
+}
+
+@media screen and (min-width:640px) {
+
+  /* Force full height on all parents up to the video elements
+   * this way we can ensure the aspect ratio and use height 100%
+   * on the video element
+   * */
+  html, body, #main,
+  .video-layout-wrapper,
+  .conversation {
+    height: 100%;
+  }
+
+  .standalone .conversation-toolbar {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+  }
+
+  .fx-embedded .local-stream {
+    position: fixed;
+  }
+
+  .standalone .local-stream {
+    position: absolute;
+    right: 15px;
+    bottom: 15px;
+    width: 20%;
+    height: 20%;
+    max-width: 400px;
+    max-height: 300px;
+  }
+
+  /* Nested video elements */
+  .conversation .media.nested {
+    position: relative;
+    height: 100%;
+  }
+
+  .standalone .remote_wrapper {
+    position: relative;
+    width: 100%;
+    height: 100%;
+  }
+
+  .standalone {
+    max-width: 1000px;
+    margin: 0 auto;
+  }
+}
+
+@media screen and (max-width:640px) {
+  .standalone .video-layout-wrapper,
+  .standalone .conversation {
+    height: 100%;
+  }
+
+  .standalone .media {
+    height: 90%;
+  }
+
+  .standalone .OT_subscriber {
+    height: 100%;
+    width: auto;
+  }
+
+  .standalone .media.nested {
+    min-height: 500px;
+  }
+
+  .standalone .local-stream {
+    flex: 1;
+    min-width: 120px;
+    min-height: 150px;
+    width: 100%;
+    box-shadow: none;
+  }
+
+  /* Nested video elements */
+  .conversation .media.nested {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    flex: 1 1 0%;
+  }
+
+  .standalone .video_wrapper.remote_wrapper {
+    /* Because of OT markup we need to set a high flex value
+     * Flex rule assures remote and local streams stack on top of eachother
+     * Computed width is not 100% unless the `width` rule */
+    flex: 2;
+    width: 100%;
+    position: relative;
+  }
+}
+
+@media screen and (max-width:420px) {
+  /* Restore video height so that we get
+   * vertical centering for free on a small screen
+   **/
+  .standalone .conversation .media video {
+    height: 100%;
+  }
+}
+
--- a/browser/components/loop/content/shared/css/panel.css
+++ b/browser/components/loop/content/shared/css/panel.css
@@ -1,95 +1,199 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* Panel styles */
+
 .panel {
-  /* XXX force proper content positioning by adding extra margin space
-   * taken away by reset.css
-   */
-  margin-top: 7px;
-  margin-bottom: 7px;
-
   /* hide the extra margin space that the panel resizer now wants to show */
   overflow: hidden;
 }
 
-.spacer {
-  margin-bottom: 1em;
+/* Notifications displayed over tabs */
+
+.panel .messages {
+  margin: 0;
+}
+
+.panel .messages .alert {
+  margin: 0;
 }
 
-.share {
-  background: #fbfbfb;
+/* Tabs and tab selection buttons */
+
+.tab-view {
+  display: flex;
+  flex-direction: row;
+  padding: 10px;
+  border-bottom: 1px solid #ccc;
+  background-color: #fbfbfb;
+  color: #000;
+  border-top-right-radius: 2px;
+  border-top-left-radius: 2px;
+  list-style: none;
+}
+
+.tab-view > li {
+  flex: 1;
+  text-align: center;
+  color: #ccc;
+  border-right: 1px solid #ccc;
+  padding: 0 10px;
+  height: 16px;
+  cursor: pointer;
+  background-repeat: no-repeat;
+  background-size: 16px 16px;
+  background-position: center;
 }
 
-.share .description,
-.share .action input,
-.share > .action > .invite > .url-actions {
-  margin: 14px 14px 0 14px;
+.tab-view > li:last-child {
+  border-right-style: none;
+}
+
+.tab-view > li[data-tab-name="call"] {
+  background-image: url("../img/icons-16x16.svg#precall");
+}
+
+.tab-view > li[data-tab-name="call"]:hover {
+  background-image: url("../img/icons-16x16.svg#precall-hover");
+}
+
+.tab-view > li[data-tab-name="call"].selected {
+  background-image: url("../img/icons-16x16.svg#precall-active");
+}
+
+.tab-view > li[data-tab-name="contacts"] {
+  background-image: url("../img/icons-16x16.svg#contacts");
 }
 
-.share .description {
+.tab-view > li[data-tab-name="contacts"]:hover {
+  background-image: url("../img/icons-16x16.svg#contacts-hover");
+}
+
+.tab-view > li[data-tab-name="contacts"].selected {
+  background-image: url("../img/icons-16x16.svg#contacts-active");
+}
+
+.tab {
+  display: none;
+}
+
+.tab.selected {
+  display: block;
+}
+
+/* Content area and input fields */
+
+.content-area {
+  padding: 14px;
+}
+
+.content-area header {
   font-weight: 700;
 }
 
-.share .action input {
-  border: 1px solid #ccc; /* Overriding background style for a text input (see
-                             below) resets its borders to a weird beveled style;
-                             defining a default 1px border solves the issue. */
-  font-size: 1em;
+.content-area label {
+  display: block;
+  width: 100%;
+  margin-top: 10px;
+  font-size: 10px;
+  color: #777;
+}
+
+.content-area input {
+  display: block;
+  width: 100%;
+  outline: none;
+  border-radius: 2px;
+  margin: 5px 0;
+  border: 1px solid #ccc;
+  height: 24px;
   padding: 0 10px;
-  border-radius: 2px;
-  outline: 0;
-  height: 26px;
-  width: calc(100% - 28px);
 }
 
-.share .action input.pending {
-  background-image: url(../img/loading-icon.gif);
-  background-repeat: no-repeat;
-  background-position: right;
+.content-area input:invalid {
+  box-shadow: none;
+}
+
+.content-area input:not(.pristine):invalid {
+  border-color: #d74345;
+  box-shadow: 0 0 4px #c43c3e;
+}
+
+/* Buttons */
+
+.button-group {
+  display: flex;
+  flex-direction: row;
+  width: 100%;
+}
+
+.button-group > .button {
+  flex: 1;
+  margin: 0 7px;
 }
 
-.share .action .btn {
-  background-color: #0096DD;
-  border: 1px solid #0095DD;
-  color: #fff;
-  width: 50%;
+.button-group > .button:first-child {
+  -moz-margin-start: 0;
+}
+
+.button-group > .button:last-child {
+  -moz-margin-end: 0;
+}
+
+.button {
+  padding: 2px 5px;
+  background-color: #fbfbfb;
+  color: #333;
+  border: 1px solid #c1c1c1;
+  border-radius: 2px;
   height: 26px;
-  text-align: center;
+  font-size: 12px;
+}
+
+.button:hover {
+  background-color: #ebebeb;
 }
 
-.share > .action .btn:hover {
-  background-color: #008ACB;
-  border: 1px solid #008ACB;
+.button:hover:active {
+  background-color: #ccc;
+  color: #111;
+}
+
+.button.button-accept {
+  background-color: #74bf43;
+  border-color: #74bf43;
+  color: #fff;
 }
 
-.share > .action > .invite > .url-actions > .btn:first-child {
-  -moz-margin-end: 1em;
+.button.button-accept:hover {
+  background-color: #6cb23e;
+  border-color: #6cb23e;
+  color: #fff;
 }
 
-/* Specific cases */
-
-.panel #messages .alert {
-  margin-bottom: 0;
+.button.button-accept:hover:active {
+  background-color: #64a43a;
+  border-color: #64a43a;
+  color: #fff;
 }
 
-/* Dropdown menu (shared styles) */
+/* Dropdown menu */
 
 .dropdown {
   position: relative;
 }
 
 .dropdown-menu {
   position: absolute;
   top: -28px;
   left: 0;
-  background: #fdfdfd;
+  background-color: #fdfdfd;
   box-shadow: 0 1px 3px rgba(0,0,0,.3);
   list-style: none;
   padding: 5px;
   border-radius: 2px;
 }
 
 body[dir=rtl] .dropdown-menu-item {
   left: auto;
@@ -104,81 +208,134 @@ body[dir=rtl] .dropdown-menu-item {
   border: 1px solid transparent;
   border-radius: 2px;
   font-size: 1em;
   white-space: nowrap;
 }
 
 .dropdown-menu-item:hover {
   border: 1px solid #ccc;
-  background: #eee;
+  background-color: #eee;
+}
+
+/* Share tab */
+
+.generate-url input {
+  margin: 14px 0;
+  outline: 0;
+  border: 1px solid #ccc; /* Overriding background style for a text input (see
+                             below) resets its borders to a weird beveled style;
+                             defining a default 1px border solves the issue. */
+  border-radius: 2px;
+  height: 26px;
+  padding: 0 10px;
+  font-size: 1em;
+}
+
+.generate-url input.pending {
+  background-image: url(../img/loading-icon.gif);
+  background-repeat: no-repeat;
+  background-position: right;
+}
+
+.generate-url .button {
+  background-color: #0096dd;
+  border-color: #0096dd;
+  color: #fff;
+}
+
+.generate-url .button:hover {
+  background-color: #008acb;
+  border-color: #008acb;
+  color: #fff;
+}
+
+.terms-service {
+  color: #888;
+  text-align: center;
+  font-size: .9em;
+}
+
+.terms-service a {
+  color: #00caee;
 }
 
 /* DnD menu */
 
 .dnd-status {
   border: 1px solid transparent;
   padding: 2px 4px;
   font-size: .9em;
   cursor: pointer;
   border-radius: 3px;
 }
 
 .dnd-status:hover {
   border: 1px solid #DDD;
-  background: #F1F1F1;
+  background-color: #f1f1f1;
 }
 
 /* Status badges -- Available/Unavailable */
 
 .status {
   display: inline-block;
   width: 8px;
   height: 8px;
   margin: 0 5px;
   border-radius: 50%;
 }
 
 .status-available {
-  background: #6cb23e;
+  background-color: #6cb23e;
 }
 
 .status-dnd {
   border: 1px solid #888;
 }
 
 /* Sign in/up link */
 
 .signin-link {
-  display: none; /* XXX This should be displayed as soon bug 979845 lands */
   flex: 2 1 auto;
   margin-top: 14px;
   border-right: 1px solid #aaa;
   padding-right: 1em;
   margin-right: 1em;
   text-align: right;
 }
 
 .signin-link a {
   font-size: .9em;
   text-decoration: none;
   color: #888;
 }
 
 /* Settings (gear) menu */
 
-.btn-settings {
-  display: none; /* XXX This should be displayed as soon bug 979845 lands */
+.button-settings {
+  display: inline-block;
+  overflow: hidden;
+  margin: 0;
+  padding: 0;
+  border: none;
+  background-color: #a5a;
+  color: #fff;
+  text-align: center;
+  text-decoration: none;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: .9em;
+  cursor: pointer;
   background: transparent url(../img/svg/glyph-settings-16x16.svg) no-repeat center center;
   background-size: contain;
   width: 12px;
   height: 12px;
 }
 
-.footer .btn-settings {
+.footer .button-settings {
   margin-top: 17px; /* used to align the gear icon with the availability dropdown menu inner text */
   opacity: .6;      /* used to "grey" the icon a little */
 }
 
 .settings-menu .dropdown-menu {
   /* The panel can't have dropdown menu overflowing its iframe boudaries;
      let's anchor it from the bottom-right, while resetting the top & left values
      set by .dropdown-menu */
@@ -207,40 +364,23 @@ body[dir=rtl] .dropdown-menu-item {
 .settings-menu .icon-signin {
   background: transparent url(../img/svg/glyph-signin-16x16.svg) no-repeat center center;
 }
 
 .settings-menu .icon-signout {
   background: transparent url(../img/svg/glyph-signout-16x16.svg) no-repeat center center;
 }
 
-/* Terms of Service */
-
-.terms-service {
-  padding: 3px 10px 10px;
-  background: #FFF;
-  text-align: center;
-  opacity: .5;
-  transition: opacity .3s;
-  font-family: 'Lucida Grande', sans-serif;
-  font-size: .9em;
-}
-
-.terms-service a {
-  color: #0095dd;
-}
-
 /* Footer */
 
 .footer {
   display: flex;
   flex-direction: row;
   flex-wrap: nowrap;
   justify-content: space-between;
   align-content: stretch;
   align-items: flex-start;
   font-size: 1em;
   border-top: 1px solid #D1D1D1;
-  background: #EAEAEA;
-  color: #7F7F7F;
+  background-color: #eaeaea;
+  color: #7f7f7f;
   padding: 14px;
-  margin-top: 14px;
 }
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/audio-call-avatar.svg
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xl="http://www.w3.org/1999/xlink" version="1.1" viewBox="129 5 252 253" width="21pc" height="253pt"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:date>2014-08-13 17:00Z</dc:date><!-- Produced by OmniGraffle Professional 5.4.4 --></metadata><defs><linearGradient x1="0" x2="1" id="Gradient" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4ba6e9"/><stop offset="1" stop-color="#4ba7bf"/></linearGradient></defs><g stroke="none" stroke-opacity="1" stroke-dasharray="none" fill="none" fill-opacity="1"><title>Canvas 1</title><g><title>Layer 1</title><rect x="129" y="5" width="252" height="252" fill="#4BA6E7"/><circle cx="255" cy="106" r="54.000088" fill="#badbeb"/><path d="M 159.01782 257 L 350.98218 257 C 351.46667 236.66908 342.1 216.2136 322.88218 200.69928 C 285.39187 170.43352 224.60813 170.43352 187.11782 200.69928 C 167.9 216.2136 158.53333 236.66909 159.01782 257 Z" fill="#badbeb"/></g></g></svg>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/icons-10x10.svg
@@ -0,0 +1,53 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink"
+     x="0px" y="0px"
+     viewBox="0 0 10 10"
+     enable-background="new 0 0 10 10"
+     xml:space="preserve">
+<style>
+use:not(:target) {
+  display: none;
+}
+
+use {
+  fill: #ccc;
+}
+
+use[id$="-hover"] {
+  fill: #444;
+}
+
+use[id$="-active"] {
+  fill: #0095dd;
+}
+
+use[id$="-white"] {
+  fill: rgba(255, 255, 255, 0.8);
+}
+</style>
+<defs style="display:none">
+  <polygon id="close-shape" fill-rule="evenodd" clip-rule="evenodd" points="10,1.717 8.336,0.049 5.024,3.369 1.663,0 0,1.668 
+    3.36,5.037 0.098,8.307 1.762,9.975 5.025,6.705 8.311,10 9.975,8.332 6.688,5.037"/>
+  <path id="dropdown-shape" fill-rule="evenodd" clip-rule="evenodd" d="M9,3L4.984,7L1,3H9z"/>
+  <polygon id="expand-shape" fill-rule="evenodd" clip-rule="evenodd" points="10,0 4.838,0 6.506,1.669 0,8.175 1.825,10 8.331,3.494 
+    10,5.162"/>
+  <rect id="minimize-shape" y="3.6" fill-rule="evenodd" clip-rule="evenodd" width="10" height="2.8"/>
+</defs>
+<use id="close"               xlink:href="#close-shape"/>
+<use id="close-active"        xlink:href="#close-shape"/>
+<use id="close-disabled"      xlink:href="#close-shape"/>
+<use id="dropdown"            xlink:href="#dropdown-shape"/>
+<use id="dropdown-white"      xlink:href="#dropdown-shape"/>
+<use id="dropdown-active"     xlink:href="#dropdown-shape"/>
+<use id="dropdown-disabled"   xlink:href="#dropdown-shape"/>
+<use id="expand"              xlink:href="#expand-shape"/>
+<use id="expand-active"       xlink:href="#expand-shape"/>
+<use id="expand-disabled"     xlink:href="#expand-shape"/>
+<use id="minimize"            xlink:href="#minimize-shape"/>
+<use id="minimize-active"     xlink:href="#minimize-shape"/>
+<use id="minimize-disabled"   xlink:href="#minimize-shape"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/icons-14x14.svg
@@ -0,0 +1,134 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink"
+     x="0px" y="0px"
+     viewBox="0 0 14 14"
+     enable-background="new 0 0 14 14"
+     xml:space="preserve">
+<style>
+use:not(:target) {
+  display: none;
+}
+
+use {
+  fill: #ccc;
+}
+
+use[id$="-hover"] {
+  fill: #444;
+}
+
+use[id$="-active"] {
+  fill: #0095dd;
+}
+
+use[id$="-white"] {
+  fill: #fff;
+}
+</style>
+<defs style="display:none">
+  <path id="audio-shape" fill-rule="evenodd" clip-rule="evenodd" d="M9.571,6.143v1.714c0,1.42-1.151,2.571-2.571,2.571
+    c-1.42,0-2.571-1.151-2.571-2.571V6.143H3.571v1.714c0,1.597,1.093,2.935,2.571,3.316v0.97H5.714c-0.56,0-1.034,0.358-1.211,0.857
+    h4.993c-0.177-0.499-0.651-0.857-1.211-0.857H7.857v-0.97c1.478-0.381,2.571-1.719,2.571-3.316V6.143H9.571z M7,10
+    c1.183,0,2.143-0.959,2.143-2.143V3.143C9.143,1.959,8.183,1,7,1C5.817,1,4.857,1.959,4.857,3.143v4.714C4.857,9.041,5.817,10,7,10
+    z"/>
+  <g id="facemute-shape">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M12.174,3.551L9.568,5.856V5.847L3.39,11.49h5.066
+      c0.613,0,1.111-0.533,1.111-1.19V8.526l2.606,2.304C12.4,11.071,12.71,11.142,13,11.078V3.302C12.71,3.239,12.4,3.309,12.174,3.551
+      z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M12.395,2.617l-0.001-0.001l-0.809-0.884l-2.102,1.92
+      C9.316,3.221,8.919,2.918,8.457,2.918H2.111C1.498,2.918,1,3.451,1,4.109v6.191c0,0.318,0.118,0.607,0.306,0.821l-0.288,0.263
+      l0.809,0.884l0.001,0.001l0.853-0.779l6.887-6.29L12.395,2.617z"/>
+  </g>
+  <path id="hangup-shape" fill-rule="evenodd" clip-rule="evenodd" d="M13,11.732c-0.602,0.52-1.254,0.946-1.941,1.267
+    c-1.825-0.337-4.164-1.695-6.264-3.795C2.696,7.106,1.339,4.769,1,2.945c0.321-0.688,0.748-1.341,1.268-1.944l2.528,2.855
+    C4.579,4.153,4.377,4.454,4.209,4.759L4.22,4.77C3.924,5.42,4.608,6.833,5.889,8.114c1.281,1.28,2.694,1.965,3.343,1.669
+    l0.011,0.011c0.305-0.168,0.606-0.37,0.904-0.587L13,11.732z"/>
+  <path id="incoming-shape" fill-rule="evenodd" clip-rule="evenodd" d="M2.745,7.558l0.637,0.669c0.04,0.041,0.085,0.073,0.134,0.1
+    l3.249,3.313c0.38,0.393,0.915,0.478,1.197,0.186l0.638-0.676c0.281-0.292,0.2-0.848-0.18-1.244L7.097,8.558h3.566
+    c0.419,0,0.759-0.34,0.759-0.759V6.28c0-0.419-0.34-0.759-0.759-0.759H7.059l1.42-1.443c0.381-0.392,0.461-0.945,0.18-1.234
+    l-0.637-0.67C7.74,1.883,7.204,1.966,6.824,2.359L3.55,5.688C3.487,5.717,3.43,5.755,3.381,5.806L2.745,6.482
+    c-0.131,0.137-0.183,0.332-0.162,0.54C2.562,7.229,2.613,7.423,2.745,7.558z"/>
+  <path id="link-shape" fill-rule="evenodd" clip-rule="evenodd" d="M7.359,6.107c0.757-0.757,0.757-1.995,0-2.752
+    L5.573,1.568c-0.757-0.757-1.995-0.757-2.752,0L1.568,2.82c-0.757,0.757-0.757,1.995,0,2.752l1.787,1.787
+    c0.757,0.757,1.995,0.757,2.752,0L6.266,7.2L6.8,7.734L6.641,7.893c-0.757,0.757-0.757,1.995,0,2.752l1.787,1.787
+    c0.757,0.757,1.995,0.757,2.752,0l1.253-1.253c0.757-0.757,0.757-1.995,0-2.752l-1.787-1.787c-0.757-0.757-1.995-0.757-2.752,0
+    L7.734,6.8L7.2,6.266L7.359,6.107z M9.87,7.868l1.335,1.335c0.294,0.294,0.294,0.774,0,1.068l-0.934,0.934
+    c-0.294,0.294-0.774,0.294-1.068,0L7.868,9.87c-0.294-0.294-0.294-0.774,0-1.068L8.13,9.064c0.294,0.294,0.744,0.324,1.001,0.067
+    C9.388,8.874,9.358,8.424,9.064,8.13L8.802,7.868C9.096,7.574,9.577,7.574,9.87,7.868z M4.13,6.132L2.795,4.797
+    c-0.294-0.294-0.294-0.774,0-1.068l0.934-0.934c0.294-0.294,0.774-0.294,1.068,0L6.132,4.13c0.294,0.294,0.294,0.774,0,1.068
+    L5.86,4.926C5.567,4.632,5.116,4.602,4.859,4.859C4.602,5.116,4.632,5.567,4.926,5.86l0.272,0.272
+    C4.904,6.426,4.423,6.426,4.13,6.132z"/>
+  <g id="mute-shape">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M5.186,9.492L5.49,9.188l3.822-3.822l2.354-2.354l-0.848-0.848
+      L9.312,3.669V3.142C9.312,1.959,8.352,1,7.169,1C5.986,1,5.026,1.959,5.026,3.142v4.715c0,0.032,0.001,0.064,0.002,0.096
+      L4.643,8.338c-0.03-0.156-0.046-0.317-0.046-0.481V6.142H3.741v1.715c0,0.414,0.073,0.81,0.208,1.176l-1.615,1.615l0.848,0.848
+      l1.398-1.398v0L5.186,9.492z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M9.312,7.857V6.045L5.829,9.528C6.196,9.824,6.662,10,7.169,10
+      C8.352,10,9.312,9.04,9.312,7.857z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M9.741,7.857c0,1.42-1.151,2.572-2.572,2.572
+      c-0.625,0-1.199-0.223-1.645-0.595l-0.605,0.605c0.395,0.344,0.87,0.599,1.393,0.734v0.97H5.884c-0.56,0-1.034,0.359-1.212,0.858
+      h4.994c-0.178-0.499-0.652-0.858-1.212-0.858H8.026v-0.97c1.478-0.38,2.572-1.718,2.572-3.316V6.142H9.741V7.857z"/>
+  </g>
+  <path id="pause-shape" fill-rule="evenodd" clip-rule="evenodd" d="M4.75,1h-1.5C2.836,1,2.5,1.336,2.5,1.75v10.5
+    C2.5,12.664,2.836,13,3.25,13h1.5c0.414,0,0.75-0.336,0.75-0.75V1.75C5.5,1.336,5.164,1,4.75,1z M10.75,1h-1.5
+    C8.836,1,8.5,1.336,8.5,1.75v10.5C8.5,12.664,8.836,13,9.25,13h1.5c0.414,0,0.75-0.336,0.75-0.75V1.75C11.5,1.336,11.164,1,10.75,1
+    z"/>
+  <path id="video-shape" fill-rule="evenodd" clip-rule="evenodd" d="M12.175,3.347L9.568,5.651V3.905c0-0.657-0.497-1.19-1.111-1.19
+    H2.111C1.498,2.714,1,3.247,1,3.905v6.191c0,0.658,0.498,1.19,1.111,1.19h6.345c0.614,0,1.111-0.533,1.111-1.19V8.322l2.607,2.305
+    C12.4,10.867,12.71,10.938,13,10.874V3.099C12.71,3.035,12.4,3.106,12.175,3.347z"/>
+  <g id="volume-shape">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M3.513,4.404H1.896c-0.417,0-0.756,0.338-0.756,0.755v3.679
+      c0,0.417,0.338,0.755,0.756,0.755H3.51l2.575,2.575c0.261,0.261,0.596,0.4,0.938,0.422V1.409C6.682,1.431,6.346,1.57,6.085,1.831
+      L3.513,4.404z M8.555,5.995C8.619,6.32,8.653,6.656,8.653,7c0,0.344-0.034,0.679-0.098,1.004l0.218,0.142
+      C8.852,7.777,8.895,7.393,8.895,7c0-0.394-0.043-0.777-0.123-1.147L8.555,5.995z M12.224,3.6l-0.475,0.31
+      c0.359,0.962,0.557,2.003,0.557,3.09c0,1.087-0.198,2.128-0.557,3.09l0.475,0.31c0.41-1.054,0.635-2.201,0.635-3.4
+      C12.859,5.8,12.634,4.654,12.224,3.6z M10.061,5.012C10.25,5.642,10.353,6.308,10.353,7c0,0.691-0.103,1.358-0.293,1.987
+      l0.351,0.229C10.634,8.517,10.756,7.772,10.756,7c0-0.773-0.121-1.517-0.345-2.216L10.061,5.012z"/>
+    <path d="M7.164,12.74l-0.15-0.009c-0.389-0.024-0.754-0.189-1.028-0.463L3.452,9.735H1.896
+      C1.402,9.735,1,9.333,1,8.838V5.16c0-0.494,0.402-0.896,0.896-0.896h1.558l2.531-2.531C6.26,1.458,6.625,1.293,7.014,1.269
+      l0.15-0.009V12.74z M1.896,4.545c-0.339,0-0.615,0.276-0.615,0.615v3.679c0,0.339,0.276,0.615,0.615,0.615h1.672l2.616,2.616
+      c0.19,0.19,0.434,0.316,0.697,0.363V1.568C6.619,1.615,6.375,1.741,6.185,1.931L3.571,4.545H1.896z M12.292,10.612l-0.714-0.467
+      l0.039-0.105C11.981,9.067,12.165,8.044,12.165,7c0-1.044-0.184-2.067-0.548-3.041l-0.039-0.105l0.714-0.467l0.063,0.162
+      C12.783,4.649,13,5.81,13,7s-0.217,2.351-0.645,3.451L12.292,10.612z M11.92,10.033l0.234,0.153
+      c0.374-1.019,0.564-2.09,0.564-3.186s-0.19-2.167-0.564-3.186L11.92,3.966C12.27,4.94,12.447,5.96,12.447,7
+      C12.447,8.04,12.27,9.059,11.92,10.033z M10.489,9.435L9.895,9.047l0.031-0.101C10.116,8.315,10.212,7.66,10.212,7
+      c0-0.661-0.096-1.316-0.287-1.947L9.895,4.952l0.594-0.388l0.056,0.176C10.779,5.471,10.897,6.231,10.897,7
+      c0,0.769-0.118,1.529-0.351,2.259L10.489,9.435z M10.225,8.926l0.106,0.069C10.52,8.348,10.615,7.677,10.615,7
+      c0-0.677-0.095-1.348-0.284-1.996l-0.106,0.07C10.403,5.699,10.494,6.347,10.494,7C10.494,7.652,10.403,8.3,10.225,8.926z
+       M8.867,8.376L8.398,8.07l0.018-0.093C8.48,7.654,8.512,7.325,8.512,7S8.48,6.345,8.417,6.022L8.398,5.929l0.469-0.306l0.043,0.2
+      C8.994,6.211,9.036,6.607,9.036,7c0,0.393-0.042,0.789-0.126,1.176L8.867,8.376z"/>
+  </g>
+</defs>
+<use id="audio"               xlink:href="#audio-shape"/>
+<use id="audio-active"        xlink:href="#audio-shape"/>
+<use id="audio-disabled"      xlink:href="#audio-shape"/>
+<use id="facemute"            xlink:href="#facemute-shape"/>
+<use id="facemute-active"     xlink:href="#facemute-shape"/>
+<use id="facemute-disabled"   xlink:href="#facemute-shape"/>
+<use id="hangup"              xlink:href="#hangup-shape"/>
+<use id="hangup-active"       xlink:href="#hangup-shape"/>
+<use id="hangup-disabled"     xlink:href="#hangup-shape"/>
+<use id="incoming"            xlink:href="#incoming-shape"/>
+<use id="incoming-active"     xlink:href="#incoming-shape"/>
+<use id="incoming-disabled"   xlink:href="#incoming-shape"/>
+<use id="link"                xlink:href="#link-shape"/>
+<use id="link-active"         xlink:href="#link-shape"/>
+<use id="link-disabled"       xlink:href="#link-shape"/>
+<use id="mute"                xlink:href="#mute-shape"/>
+<use id="mute-active"         xlink:href="#mute-shape"/>
+<use id="mute-disabled"       xlink:href="#mute-shape"/>
+<use id="pause"               xlink:href="#pause-shape"/>
+<use id="pause-active"        xlink:href="#pause-shape"/>
+<use id="pause-disabled"      xlink:href="#pause-shape"/>
+<use id="video"               xlink:href="#video-shape"/>
+<use id="video-white"         xlink:href="#video-shape"/>
+<use id="video-active"        xlink:href="#video-shape"/>
+<use id="video-disabled"      xlink:href="#video-shape"/>
+<use id="volume"              xlink:href="#volume-shape"/>
+<use id="volume-active"       xlink:href="#volume-shape"/>
+<use id="volume-disabled"     xlink:href="#volume-shape"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/icons-16x16.svg
@@ -0,0 +1,122 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink"
+     x="0px" y="0px"
+     viewBox="0 0 16 16"
+     enable-background="new 0 0 16 16"
+     xml:space="preserve">
+<style>
+use:not(:target) {
+  display: none;
+}
+
+use {
+  fill: #ccc;
+}
+
+use[id$="-hover"] {
+  fill: #444;
+}
+
+use[id$="-active"] {
+  fill: #0095dd;
+}
+
+use[id$="-red"] {
+  fill: #d74345
+}
+</style>
+<defs style="display:none">
+  <path id="audio-shape" fill-rule="evenodd" clip-rule="evenodd" d="M11.429,6.857v2.286c0,1.894-1.535,3.429-3.429,3.429
+    c-1.894,0-3.429-1.535-3.429-3.429V6.857H3.429v2.286c0,2.129,1.458,3.913,3.429,4.422v1.293H6.286
+    c-0.746,0-1.379,0.477-1.615,1.143h6.658c-0.236-0.665-0.869-1.143-1.615-1.143H9.143v-1.293c1.971-0.508,3.429-2.292,3.429-4.422
+    V6.857H11.429z M8,12c1.578,0,2.857-1.279,2.857-2.857V2.857C10.857,1.279,9.578,0,8,0C6.422,0,5.143,1.279,5.143,2.857v6.286
+    C5.143,10.721,6.422,12,8,12z"/>
+  <path id="block-shape" fill-rule="evenodd" clip-rule="evenodd" d="M8,0C3.582,0,0,3.582,0,8c0,4.418,3.582,8,8,8
+    c4.418,0,8-3.582,8-8C16,3.582,12.418,0,8,0z M8,2.442c1.073,0,2.075,0.301,2.926,0.821l-7.673,7.673
+    C2.718,10.085,2.408,9.079,2.408,8C2.408,4.931,4.911,2.442,8,2.442z M8,13.557c-1.073,0-2.075-0.301-2.926-0.821l7.673-7.673
+    C13.282,5.915,13.592,6.921,13.592,8C13.592,11.069,11.089,13.557,8,13.557z"/>
+  <path id="contacts-shape" fill-rule="evenodd" clip-rule="evenodd" d="M8,6.526c1.802,0,3.263-1.461,3.263-3.263
+    C11.263,1.461,9.802,0,8,0C6.198,0,4.737,1.461,4.737,3.263C4.737,5.066,6.198,6.526,8,6.526z M14.067,11.421c0,0,0-0.001,0-0.001
+    c0-1.676-1.397-3.119-3.419-3.807L8.001,10.26L5.354,7.613C3.331,8.3,1.933,9.744,1.933,11.42v0.001H1.93
+    c0,1.679,0.328,3.246,0.896,4.579h10.348c0.568-1.333,0.896-2.9,0.896-4.579H14.067z"/>
+  <g id="google-shape">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M8.001,9.278c-0.9,0.03-1.989,0.454-2.144,1.274
+      c-0.292,1.54,1.284,2.004,2.455,1.932c1.097-0.067,1.737-0.593,1.813-1.26c0.063-0.554-0.184-1.153-0.959-1.644
+      c-0.142-0.09-0.28-0.185-0.413-0.282C8.504,9.291,8.25,9.27,8.001,9.278z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M7.381,3.409C6.638,3.64,6.32,4.405,6.627,5.61
+      C6.908,6.708,7.78,7.322,8.569,7.104c0.77-0.213,0.987-1.021,0.847-1.873C9.201,3.929,8.261,3.136,7.381,3.409z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M8,0C3.582,0,0,3.582,0,8s3.582,8,8,8c4.418,0,8-3.582,8-8
+      S12.418,0,8,0z M10.544,4.471c0.17,0.453,0.194,0.954,0.021,1.416c-0.163,0.436-0.495,0.811-0.982,1.096
+      C9.307,7.146,9.167,7.351,9.151,7.548c-0.045,0.575,0.658,0.993,1.064,1.297c0.889,0.666,1.236,1.758,0.648,2.813
+      c-0.562,1.007-1.901,1.457-3.322,1.462c-1.766-0.008-2.88-0.817-2.938-1.918C4.527,9.779,5.987,9.101,7.307,8.947
+      c0.369-0.043,0.7-0.036,1.01-0.014C7.85,8.625,7.675,7.998,7.914,7.58c0.062-0.109,0.023-0.072-0.095-0.054
+      C6.739,7.689,5.628,6.985,5.367,5.92c-0.132-0.54-0.05-1.105,0.156-1.547C5.97,3.413,6.964,2.88,8.067,2.88
+      c1.147,0,2.209,0,3.334,0.009L10.612,3.4H9.714C10.093,3.665,10.384,4.046,10.544,4.471z"/>
+  </g>
+  <path id="history-shape" fill-rule="evenodd" clip-rule="evenodd" d="M8,16c-4.418,0-8-3.582-8-8c0-4.418,3.582-8,8-8
+    c4.418,0,8,3.582,8,8C16,12.418,12.418,16,8,16z M8,2.442C4.911,2.442,2.408,4.931,2.408,8c0,3.069,2.504,5.557,5.592,5.557
+    S13.592,11.069,13.592,8C13.592,4.931,11.089,2.442,8,2.442z M7.649,9.048C7.206,8.899,6.882,8.493,6.882,8V4.645
+    c0-0.618,0.501-1.119,1.118-1.119c0.618,0,1.119,0.501,1.119,1.119v3.078c1.176,1.22,2.237,3.633,2.237,3.633
+    S8.844,10.252,7.649,9.048z"/>
+  <path id="precall-shape" fill-rule="evenodd" clip-rule="evenodd" d="M8.014,0.003c-4.411,0-7.987,3.576-7.987,7.986
+    c0,1.642,0.496,3.168,1.346,4.437L0,15.997l3.568-1.372c1.271,0.853,2.8,1.352,4.446,1.352c4.411,0,7.986-3.576,7.986-7.987
+    C16,3.579,12.424,0.003,8.014,0.003z"/>
+  <path id="settings-shape" fill-rule="evenodd" clip-rule="evenodd" d="M14.77,8c0,0.804,0.262,1.548,0.634,1.678L16,9.887
+    c-0.205,0.874-0.553,1.692-1.011,2.434l-0.567-0.272c-0.355-0.171-1.066,0.17-1.635,0.738c-0.569,0.569-0.909,1.279-0.738,1.635
+    l0.273,0.568c-0.741,0.46-1.566,0.79-2.438,0.998l-0.205-0.584c-0.13-0.372-0.874-0.634-1.678-0.634s-1.548,0.262-1.678,0.634
+    l-0.209,0.596c-0.874-0.205-1.692-0.553-2.434-1.011l0.272-0.567c0.171-0.355-0.17-1.066-0.739-1.635
+    c-0.568-0.568-1.279-0.909-1.635-0.738l-0.568,0.273c-0.46-0.741-0.79-1.566-0.998-2.439l0.584-0.205
+    C0.969,9.547,1.231,8.804,1.231,8c0-0.804-0.262-1.548-0.634-1.678L0,6.112c0.206-0.874,0.565-1.685,1.025-2.427l0.554,0.266
+    c0.355,0.171,1.066-0.17,1.635-0.738c0.569-0.568,0.909-1.28,0.739-1.635L3.686,1.025c0.742-0.46,1.553-0.818,2.427-1.024
+    l0.209,0.596C6.453,0.969,7.197,1.23,8.001,1.23s1.548-0.262,1.678-0.634l0.209-0.596c0.874,0.205,1.692,0.553,2.434,1.011
+    l-0.272,0.567c-0.171,0.355,0.17,1.066,0.738,1.635c0.569,0.568,1.279,0.909,1.635,0.738l0.568-0.273
+    c0.46,0.741,0.79,1.566,0.998,2.438l-0.584,0.205C15.032,6.452,14.77,7.196,14.77,8z M8.001,3.661C5.604,3.661,3.661,5.603,3.661,8
+    c0,2.397,1.943,4.34,4.339,4.34c2.397,0,4.339-1.943,4.339-4.34C12.34,5.603,10.397,3.661,8.001,3.661z"/>
+  <path id="tag-shape" fill-rule="evenodd" clip-rule="evenodd" d="M15.578,7.317L9.659,1.398
+    C9.374,1.033,8.955,0.777,8.471,0.761L2.556,0C1.72-0.027-0.027,1.72,0,2.556l0.761,5.916c0.016,0.484,0.272,0.902,0.637,1.188
+    l5.919,5.919c0.591,0.591,1.584,0.557,2.218-0.076l5.966-5.966C16.135,8.902,16.169,7.909,15.578,7.317z M4.222,4.163
+    c-0.511,0.511-1.339,0.511-1.85,0c-0.511-0.511-0.511-1.339,0-1.85c0.511-0.511,1.339-0.511,1.85,0
+    C4.733,2.823,4.733,3.652,4.222,4.163z"/>
+  <path id="unblock-shape" fill-rule="evenodd" clip-rule="evenodd" d="M8,16c-4.418,0-8-3.582-8-8c0-4.418,3.582-8,8-8
+    c4.418,0,8,3.582,8,8C16,12.418,12.418,16,8,16z M8,2.442C4.911,2.442,2.408,4.931,2.408,8c0,3.069,2.504,5.557,5.592,5.557
+    S13.592,11.069,13.592,8C13.592,4.931,11.089,2.442,8,2.442z"/>
+  <path id="video-shape" fill-rule="evenodd" clip-rule="evenodd" d="M14.9,3.129l-3.476,3.073V3.873c0-0.877-0.663-1.587-1.482-1.587
+    H1.482C0.663,2.286,0,2.996,0,3.873v8.254c0,0.877,0.663,1.587,1.482,1.587h8.461c0.818,0,1.482-0.711,1.482-1.587V9.762
+    l3.476,3.073c0.3,0.321,0.714,0.416,1.1,0.331V2.798C15.614,2.713,15.2,2.808,14.9,3.129z"/>
+</defs>
+<use id="audio"               xlink:href="#audio-shape"/>
+<use id="audio-hover"         xlink:href="#audio-shape"/>
+<use id="audio-active"        xlink:href="#audio-shape"/>
+<use id="block"               xlink:href="#block-shape"/>
+<use id="block-red"           xlink:href="#block-shape"/>
+<use id="block-hover"         xlink:href="#block-shape"/>
+<use id="block-active"        xlink:href="#block-shape"/>
+<use id="contacts"            xlink:href="#contacts-shape"/>
+<use id="contacts-hover"      xlink:href="#contacts-shape"/>
+<use id="contacts-active"     xlink:href="#contacts-shape"/>
+<use id="google"              xlink:href="#google-shape"/>
+<use id="google-hover"        xlink:href="#google-shape"/>
+<use id="google-active"       xlink:href="#google-shape"/>
+<use id="history"             xlink:href="#history-shape"/>
+<use id="history-hover"       xlink:href="#history-shape"/>
+<use id="history-active"      xlink:href="#history-shape"/>
+<use id="precall"             xlink:href="#precall-shape"/>
+<use id="precall-hover"       xlink:href="#precall-shape"/>
+<use id="precall-active"      xlink:href="#precall-shape"/>
+<use id="settings"            xlink:href="#settings-shape"/>
+<use id="settings-hover"      xlink:href="#settings-shape"/>
+<use id="settings-active"     xlink:href="#settings-shape"/>
+<use id="tag"                 xlink:href="#tag-shape"/>
+<use id="tag-hover"           xlink:href="#tag-shape"/>
+<use id="tag-active"          xlink:href="#tag-shape"/>
+<use id="unblock"             xlink:href="#unblock-shape"/>
+<use id="unblock-hover"       xlink:href="#unblock-shape"/>
+<use id="unblock-active"      xlink:href="#unblock-shape"/>
+<use id="video"               xlink:href="#video-shape"/>
+<use id="video-hover"         xlink:href="#video-shape"/>
+<use id="video-active"        xlink:href="#video-shape"/>
+</svg>
--- a/browser/components/loop/content/shared/js/feedbackApiClient.js
+++ b/browser/components/loop/content/shared/js/feedbackApiClient.js
@@ -46,17 +46,18 @@ loop.FeedbackAPIClient = (function($, _)
      */
     _supportedFields: ["happy",
                        "category",
                        "description",
                        "product",
                        "platform",
                        "version",
                        "channel",
-                       "user_agent"],
+                       "user_agent",
+                       "url"],
 
     /**
      * Creates a formatted payload object compliant with the Feedback API spec
      * against validated field data.
      *
      * @param  {Object} fields Feedback initial values.
      * @return {Object}        Formatted payload object.
      * @throws {Error}         If provided values are invalid
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/mixins.js
@@ -0,0 +1,95 @@
+/* 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.mixins = (function() {
+  "use strict";
+
+  /**
+   * Root object, by default set to window.
+   * @type {DOMWindow|Object}
+   */
+  var rootObject = window;
+
+  /**
+   * Sets a new root object. This is useful for testing native DOM events so we
+   * can fake them.
+   *
+   * @param {Object}
+   */
+  function setRootObject(obj) {
+    console.info("loop.shared.mixins: rootObject set to " + obj);
+    rootObject = obj;
+  }
+
+  /**
+   * Dropdown menu mixin.
+   * @type {Object}
+   */
+  var DropdownMenuMixin = {
+    getInitialState: function() {
+      return {showMenu: false};
+    },
+
+    _onBodyClick: function() {
+      this.setState({showMenu: false});
+    },
+
+    componentDidMount: function() {
+      rootObject.document.body.addEventListener("click", this._onBodyClick);
+    },
+
+    componentWillUnmount: function() {
+      rootObject.document.body.removeEventListener("click", this._onBodyClick);
+    },
+
+    showDropdownMenu: function() {
+      this.setState({showMenu: true});
+    },
+
+    hideDropdownMenu: function() {
+      this.setState({showMenu: false});
+    }
+  };
+
+  /**
+   * 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.
+   *
+   * @type {Object}
+   */
+  var DocumentVisibilityMixin = {
+    _onDocumentVisibilityChanged: function(event) {
+      var hidden = event.target.hidden;
+      if (hidden && typeof this.onDocumentHidden === "function") {
+        this.onDocumentHidden();
+      }
+      if (!hidden && typeof this.onDocumentVisible === "function") {
+        this.onDocumentVisible();
+      }
+    },
+
+    componentDidMount: function() {
+      rootObject.document.addEventListener(
+        "visibilitychange", this._onDocumentVisibilityChanged);
+    },
+
+    componentWillUnmount: function() {
+      rootObject.document.removeEventListener(
+        "visibilitychange", this._onDocumentVisibilityChanged);
+    }
+  };
+
+  return {
+    setRootObject: setRootObject,
+    DropdownMenuMixin: DropdownMenuMixin,
+    DocumentVisibilityMixin: DocumentVisibilityMixin
+  };
+})();
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -1,34 +1,31 @@
 /* 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.models = (function() {
+loop.shared.models = (function(l10n) {
   "use strict";
 
   /**
    * Conversation model.
    */
   var ConversationModel = Backbone.Model.extend({
     defaults: {
       connected:    false,         // Session connected flag
       ongoing:      false,         // Ongoing call flag
       callerId:     undefined,     // Loop caller id
       loopToken:    undefined,     // Loop conversation token
-      loopVersion:  undefined,     // Loop version for /calls/ information. This
-                                   // is the version received from the push
-                                   // notification and is used by the server to
-                                   // determine the pending calls
       sessionId:    undefined,     // OT session id
       sessionToken: undefined,     // OT session token
+      sessionType:  undefined,     // Hawk session type
       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")
@@ -50,59 +47,45 @@ loop.shared.models = (function() {
 
     /**
      * SDK session object.
      * @type {XXX}
      */
     session: undefined,
 
     /**
-     * Pending call timeout value.
-     * @type {Number}
-     */
-    pendingCallTimeout: undefined,
-
-    /**
-     * Pending call timer.
-     * @type {Number}
-     */
-    _pendingCallTimer: undefined,
-
-    /**
      * Constructor.
      *
      * Options:
      *
      * Required:
      * - {OT} sdk: OT SDK object.
      *
-     * Optional:
-     * - {Number} pendingCallTimeout: Pending call timeout in milliseconds
-     *                                (default: 20000).
-     *
      * @param  {Object} attributes Attributes object.
      * @param  {Object} options    Options object.
      */
     initialize: function(attributes, options) {
       options = options || {};
       if (!options.sdk) {
         throw new Error("missing required sdk");
       }
       this.sdk = options.sdk;
-      this.pendingCallTimeout = options.pendingCallTimeout || 20000;
 
-      // Ensure that any pending call timer is cleared on disconnect/error
-      this.on("session:ended session:error", this._clearPendingCallTimer, this);
+      // Set loop.debug.sdk to true in the browser, or standalone:
+      // localStorage.setItem("debug.sdk", true);
+      if (loop.shared.utils.getBoolPreference("debug.sdk")) {
+        this.sdk.setLogLevel(this.sdk.DEBUG);
+      }
     },
 
     /**
-     * Starts an incoming conversation.
+     * Indicates an incoming conversation has been accepted.
      */
-    incoming: function() {
-      this.trigger("call:incoming");
+    accepted: function() {
+      this.trigger("call:accepted");
     },
 
     /**
      * Used to indicate that an outgoing call should start any necessary
      * set-up.
      */
     setupOutgoingCall: function() {
       this.trigger("call:outgoing:setup");
@@ -110,30 +93,16 @@ loop.shared.models = (function() {
 
     /**
      * Starts an outgoing conversation.
      *
      * @param {Object} sessionData The session data received from the
      *                             server for the outgoing call.
      */
     outgoing: function(sessionData) {
-      this._clearPendingCallTimer();
-
-      // Outgoing call has never reached destination, closing - see bug 1020448
-      function handleOutgoingCallTimeout() {
-        /*jshint validthis:true */
-        if (!this.get("ongoing")) {
-          this.trigger("timeout").endSession();
-        }
-      }
-
-      // Setup pending call timeout.
-      this._pendingCallTimer = setTimeout(
-        handleOutgoingCallTimeout.bind(this), this.pendingCallTimeout);
-
       this.setOutgoingSessionData(sessionData);
       this.trigger("call:outgoing");
     },
 
     /**
      * Checks that the session is ready.
      *
      * @return {Boolean}
@@ -165,16 +134,17 @@ loop.shared.models = (function() {
      *
      * @param {Object} sessionData Conversation session information.
      */
     setIncomingSessionData: function(sessionData) {
       // Explicit property assignment to prevent later "surprises"
       this.set({
         sessionId:      sessionData.sessionId,
         sessionToken:   sessionData.sessionToken,
+        sessionType:    sessionData.sessionType,
         apiKey:         sessionData.apiKey,
         callId:         sessionData.callId,
         progressURL:    sessionData.progressURL,
         websocketToken: sessionData.websocketToken.toString(16),
         callType:       sessionData.callType || "audio-video",
         callToken:      sessionData.callToken
       });
     },
@@ -277,25 +247,16 @@ loop.shared.models = (function() {
           break;
         default:
           this.trigger("session:error", err);
           break;
       }
     },
 
     /**
-     * Clears current pending call timer, if any.
-     */
-    _clearPendingCallTimer: function() {
-      if (this._pendingCallTimer) {
-        clearTimeout(this._pendingCallTimer);
-      }
-    },
-
-    /**
      * Manages connection status
      * triggers apropriate event for connection error/success
      * http://tokbox.com/opentok/tutorials/connect-session/js/
      * http://tokbox.com/opentok/tutorials/hello-world/js/
      * http://tokbox.com/opentok/libraries/client/js/reference/SessionConnectEvent.html
      *
      * @param {error|null} error
      */
@@ -370,17 +331,56 @@ loop.shared.models = (function() {
       message: ""
     }
   });
 
   /**
    * Notification collection
    */
   var NotificationCollection = Backbone.Collection.extend({
-    model: NotificationModel
+    model: NotificationModel,
+
+    /**
+     * Adds a warning notification to the stack and renders it.
+     *
+     * @return {String} message
+     */
+    warn: function(message) {
+      this.add({level: "warning", message: message});
+    },
+
+    /**
+     * Adds a l10n warning notification to the stack and renders it.
+     *
+     * @param  {String} messageId L10n message id
+     */
+    warnL10n: function(messageId) {
+      this.warn(l10n.get(messageId));
+    },
+
+    /**
+     * Adds an error notification to the stack and renders it.
+     *
+     * @return {String} message
+     */
+    error: function(message) {
+      this.add({level: "error", message: message});
+    },
+
+    /**
+     * Adds a l10n error notification to the stack and renders it.
+     *
+     * @param  {String} messageId L10n message id
+     * @param  {Object} [l10nProps] An object with variables to be interpolated
+     *                  into the translation. All members' values must be
+     *                  strings or numbers.
+     */
+    errorL10n: function(messageId, l10nProps) {
+      this.error(l10n.get(messageId, l10nProps));
+    }
   });
 
   return {
     ConversationModel: ConversationModel,
     NotificationCollection: NotificationCollection,
     NotificationModel: NotificationModel
   };
-})();
+})(navigator.mozL10n || document.mozL10n);
deleted file mode 100644
--- a/browser/components/loop/content/shared/js/router.js
+++ /dev/null
@@ -1,189 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this file,
- * You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-/* global loop:true */
-
-var loop = loop || {};
-loop.shared = loop.shared || {};
-loop.shared.router = (function(l10n) {
-  "use strict";
-
-  /**
-   * Base Router. Allows defining a main active view and ease toggling it when
-   * the active route changes.
-   *
-   * @link http://mikeygee.com/blog/backbone.html
-   */
-  var BaseRouter = Backbone.Router.extend({
-    /**
-     * Active view.
-     * @type {Object}
-     */
-    _activeView: undefined,
-
-    /**
-     * Notifications dispatcher.
-     * @type {loop.shared.views.NotificationListView}
-     */
-    _notifier: undefined,
-
-    /**
-     * Constructor.
-     *
-     * Required options:
-     * - {loop.shared.views.NotificationListView} notifier Notifier view.
-     *
-     * @param  {Object} options Options object.
-     */
-    constructor: function(options) {
-      options = options || {};
-      if (!options.notifier) {
-        throw new Error("missing required notifier");
-      }
-      this._notifier = options.notifier;
-
-      Backbone.Router.apply(this, arguments);
-    },
-
-    /**
-     * Loads and render current active view.
-     *
-     * @param {loop.shared.views.BaseView} view View.
-     */
-    loadView: function(view) {
-      this.clearActiveView();
-      this._activeView = {type: "backbone", view: view.render().show()};
-      this.updateView(this._activeView.view.$el);
-    },
-
-    /**
-     * Renders a React component as current active view.
-     *
-     * @param {React} reactComponent React component.
-     */
-    loadReactComponent: function(reactComponent) {
-      this.clearActiveView();
-      this._activeView = {
-        type: "react",
-        view: React.renderComponent(reactComponent,
-                                    document.querySelector("#main"))
-      };
-    },
-
-    /**
-     * Clears current active view.
-     */
-    clearActiveView: function() {
-      if (!this._activeView) {
-        return;
-      }
-      if (this._activeView.type === "react") {
-        React.unmountComponentAtNode(document.querySelector("#main"));
-      } else {
-        this._activeView.view.remove();
-      }
-    },
-
-    /**
-     * Updates main div element with provided contents.
-     *
-     * @param  {jQuery} $el Element.
-     */
-    updateView: function($el) {
-      $("#main").html($el);
-    }
-  });
-
-  /**
-   * Base conversation router, implementing common behaviors when handling
-   * a conversation.
-   */
-  var BaseConversationRouter = BaseRouter.extend({
-    /**
-     * Current conversation.
-     * @type {loop.shared.models.ConversationModel}
-     */
-    _conversation: undefined,
-
-    /**
-     * Constructor. Defining it as `constructor` allows implementing an
-     * `initialize` method in child classes without needing calling this parent
-     * one. See http://backbonejs.org/#Model-constructor (same for Router)
-     *
-     * Required options:
-     * - {loop.shared.model.ConversationModel}    model    Conversation model.
-     *
-     * @param {Object} options Options object.
-     */
-    constructor: function(options) {
-      options = options || {};
-      if (!options.conversation) {
-        throw new Error("missing required conversation");
-      }
-      if (!options.client) {
-        throw new Error("missing required client");
-      }
-      this._conversation = options.conversation;
-      this._client = options.client;
-
-      this.listenTo(this._conversation, "session:ended", this._onSessionEnded);
-      this.listenTo(this._conversation, "session:peer-hungup",
-                                        this._onPeerHungup);
-      this.listenTo(this._conversation, "session:network-disconnected",
-                                        this._onNetworkDisconnected);
-      this.listenTo(this._conversation, "session:connection-error",
-                    this._notifyError);
-
-      BaseRouter.apply(this, arguments);
-    },
-
-    /**
-     * Notify the user that the connection was not possible
-     * @param {{code: number, message: string}} error
-     */
-    _notifyError: function(error) {
-      console.log(error);
-      this._notifier.errorL10n("connection_error_see_console_notification");
-      this.endCall();
-    },
-
-    /**
-     * Ends the call. This method should be overriden.
-     */
-    endCall: function() {},
-
-    /**
-     * Session has ended. Notifies the user and ends the call.
-     */
-    _onSessionEnded: function() {
-      this.endCall();
-    },
-
-    /**
-     * Peer hung up. Notifies the user and ends the call.
-     *
-     * Event properties:
-     * - {String} connectionId: OT session id
-     *
-     * @param {Object} event
-     */
-    _onPeerHungup: function() {
-      this._notifier.warnL10n("peer_ended_conversation2");
-      this.endCall();
-    },
-
-    /**
-     * Network disconnected. Notifies the user and ends the call.
-     */
-    _onNetworkDisconnected: function() {
-      this._notifier.warnL10n("network_disconnected");
-      this.endCall();
-    }
-  });
-
-  return {
-    BaseRouter: BaseRouter,
-    BaseConversationRouter: BaseConversationRouter
-  };
-})(document.webL10n || document.mozL10n);
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -24,12 +24,61 @@ loop.shared.utils = (function() {
     }
     if (navigator.platform.indexOf("Linux") !== -1) {
       platform = "linux";
     }
 
     return platform;
   }
 
+  /**
+   * Used for getting a boolean preference. It will either use the browser preferences
+   * (if navigator.mozLoop is defined) or try to get them from localStorage.
+   *
+   * @param {String} prefName The name of the preference. Note that mozLoop adds
+   *                          'loop.' to the start of the string.
+   *
+   * @return The value of the preference, or false if not available.
+   */
+  function getBoolPreference(prefName) {
+    if (navigator.mozLoop) {
+      return !!navigator.mozLoop.getLoopBoolPref(prefName);
+    }
+
+    return !!localStorage.getItem(prefName);
+  }
+
+  /**
+   * Helper for general things
+   */
+  function Helper() {
+    this._iOSRegex = /^(iPad|iPhone|iPod)/;
+  }
+
+  Helper.prototype = {
+    isFirefox: function(platform) {
+      return platform.indexOf("Firefox") !== -1;
+    },
+
+    isFirefoxOS: function(platform) {
+      // So far WebActivities are exposed only in FxOS, but they may be
+      // exposed in Firefox Desktop soon, so we check for its existence
+      // and also check if the UA belongs to a mobile platform.
+      // XXX WebActivities are also exposed in WebRT on Firefox for Android,
+      //     so we need a better check. Bug 1065403.
+      return !!window.MozActivity && /mobi/i.test(platform);
+    },
+
+    isIOS: function(platform) {
+      return this._iOSRegex.test(platform);
+    },
+
+    locationHash: function() {
+      return window.location.hash;
+    }
+  };
+
   return {
-    getTargetPlatform: getTargetPlatform
+    Helper: Helper,
+    getTargetPlatform: getTargetPlatform,
+    getBoolPreference: getBoolPreference
   };
 })();
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -7,128 +7,55 @@
 /* jshint newcap:false */
 /* global loop:true, React */
 var loop = loop || {};
 loop.shared = loop.shared || {};
 loop.shared.views = (function(_, OT, l10n) {
   "use strict";
 
   var sharedModels = loop.shared.models;
-  var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
-
-  /**
-   * L10n view. Translates resulting view DOM fragment once rendered.
-   */
-  var L10nView = (function() {
-    var L10nViewImpl   = Backbone.View.extend(), // Original View constructor
-        originalExtend = L10nViewImpl.extend;    // Original static extend fn
-
-    /**
-     * Patches View extend() method so we can hook and patch any declared render
-     * method.
-     *
-     * @return {Backbone.View} Extended view with patched render() method.
-     */
-    L10nViewImpl.extend = function() {
-      var ExtendedView   = originalExtend.apply(this, arguments),
-          originalRender = ExtendedView.prototype.render;
-
-      /**
-       * Wraps original render() method to translate contents once they're
-       * rendered.
-       *
-       * @return {Backbone.View} Extended view instance.
-       */
-      ExtendedView.prototype.render = function() {
-        if (originalRender) {
-          originalRender.apply(this, arguments);
-          l10n.translate(this.el);
-        }
-        return this;
-      };
-
-      return ExtendedView;
-    };
-
-    return L10nViewImpl;
-  })();
+  var sharedMixins = loop.shared.mixins;
 
-  /**
-   * Base view.
-   */
-  var BaseView = L10nView.extend({
-    /**
-     * Hides view element.
-     *
-     * @return {BaseView}
-     */
-    hide: function() {
-      this.$el.hide();
-      return this;
-    },
-
-    /**
-     * Shows view element.
-     *
-     * @return {BaseView}
-     */
-    show: function() {
-      this.$el.show();
-      return this;
-    },
-
-    /**
-     * Base render implementation: renders an attached template if available.
-     *
-     * Note: You need to override this if you want to do fancier stuff, eg.
-     *       rendering the template using model data.
-     *
-     * @return {BaseView}
-     */
-    render: function() {
-      if (this.template) {
-        this.$el.html(this.template());
-      }
-      return this;
-    }
-  });
+  var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
 
   /**
    * Media control button.
    *
    * Required props:
    * - {String}   scope   Media scope, can be "local" or "remote".
    * - {String}   type    Media type, can be "audio" or "video".
    * - {Function} action  Function to be executed on click.
    * - {Enabled}  enabled Stream activation status (default: true).
    */
   var MediaControlButton = React.createClass({displayName: 'MediaControlButton',
     propTypes: {
       scope: React.PropTypes.string.isRequired,
       type: React.PropTypes.string.isRequired,
       action: React.PropTypes.func.isRequired,
-      enabled: React.PropTypes.bool.isRequired
+      enabled: React.PropTypes.bool.isRequired,
+      visible: React.PropTypes.bool.isRequired
     },
 
     getDefaultProps: function() {
-      return {enabled: true};
+      return {enabled: true, visible: true};
     },
 
     handleClick: function() {
       this.props.action();
     },
 
     _getClasses: function() {
       var cx = React.addons.classSet;
       // classes
       var classesObj = {
         "btn": true,
         "media-control": true,
         "local-media": this.props.scope === "local",
-        "muted": !this.props.enabled
+        "muted": !this.props.enabled,
+        "hide": !this.props.visible
       };
       classesObj["btn-mute-" + this.props.type] = true;
       return cx(classesObj);
     },
 
     _getTitle: function(enabled) {
       var prefix = this.props.enabled ? "mute" : "unmute";
       var suffix = "button_title";
@@ -148,18 +75,18 @@ loop.shared.views = (function(_, OT, l10
   });
 
   /**
    * Conversation controls.
    */
   var ConversationToolbar = React.createClass({displayName: 'ConversationToolbar',
     getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     propTypes: {
       video: React.PropTypes.object.isRequired,
       audio: React.PropTypes.object.isRequired,
       hangup: React.PropTypes.func.isRequired,
       publishStream: React.PropTypes.func.isRequired
@@ -173,91 +100,121 @@ loop.shared.views = (function(_, OT, l10
       this.props.publishStream("video", !this.props.video.enabled);
     },
 
     handleToggleAudio: function() {
       this.props.publishStream("audio", !this.props.audio.enabled);
     },
 
     render: function() {
-      /* jshint ignore:start */
+      var cx = React.addons.classSet;
       return (
         React.DOM.ul({className: "conversation-toolbar"}, 
           React.DOM.li({className: "conversation-toolbar-btn-box"}, 
             React.DOM.button({className: "btn btn-hangup", onClick: this.handleClickHangup, 
                     title: l10n.get("hangup_button_title")}, 
               l10n.get("hangup_button_caption2")
             )
           ), 
           React.DOM.li({className: "conversation-toolbar-btn-box"}, 
             MediaControlButton({action: this.handleToggleVideo, 
                                 enabled: this.props.video.enabled, 
+                                visible: this.props.video.visible, 
                                 scope: "local", type: "video"})
           ), 
           React.DOM.li({className: "conversation-toolbar-btn-box"}, 
             MediaControlButton({action: this.handleToggleAudio, 
                                 enabled: this.props.audio.enabled, 
+                                visible: this.props.audio.visible, 
                                 scope: "local", type: "audio"})
           )
         )
       );
-      /* jshint ignore:end */
     }
   });
 
+  /**
+   * Conversation view.
+   */
   var ConversationView = React.createClass({displayName: 'ConversationView',
     mixins: [Backbone.Events],
 
     propTypes: {
       sdk: React.PropTypes.object.isRequired,
-      model: React.PropTypes.object.isRequired
+      video: React.PropTypes.object,
+      audio: React.PropTypes.object,
+      initiate: React.PropTypes.bool
     },
 
     // height set to 100%" to fix video layout on Google Chrome
     // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
     publisherConfig: {
       insertMode: "append",
       width: "100%",
       height: "100%",
       style: {
         bugDisplayMode: "off",
         buttonDisplayMode: "off",
         nameDisplayMode: "off"
       }
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        initiate: true,
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     getInitialState: function() {
       return {
         video: this.props.video,
         audio: this.props.audio
       };
     },
 
     componentWillMount: function() {
-      this.publisherConfig.publishVideo = this.props.video.enabled;
+      if (this.props.initiate) {
+        this.publisherConfig.publishVideo = this.props.video.enabled;
+      }
     },
 
     componentDidMount: function() {
-      this.listenTo(this.props.model, "session:connected",
-                                      this.startPublishing);
-      this.listenTo(this.props.model, "session:stream-created",
-                                      this._streamCreated);
-      this.listenTo(this.props.model, ["session:peer-hungup",
-                                       "session:network-disconnected",
-                                       "session:ended"].join(" "),
-                                       this.stopPublishing);
+      if (this.props.initiate) {
+        this.listenTo(this.props.model, "session:connected",
+                                        this.startPublishing);
+        this.listenTo(this.props.model, "session:stream-created",
+                                        this._streamCreated);
+        this.listenTo(this.props.model, ["session:peer-hungup",
+                                         "session:network-disconnected",
+                                         "session:ended"].join(" "),
+                                         this.stopPublishing);
+        this.props.model.startSession();
+      }
 
-      this.props.model.startSession();
+      /**
+       * 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);
+    },
+
+    updateVideoContainer: function() {
+      var localStreamParent = document.querySelector('.local .OT_publisher');
+      var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
     },
 
     componentWillUnmount: function() {
       // Unregister all local event listeners
       this.stopListening();
       this.hangup();
     },
 
@@ -333,37 +290,44 @@ loop.shared.views = (function(_, OT, l10
         this.setState({video: {enabled: enabled}});
       }
     },
 
     /**
      * Unpublishes local stream.
      */
     stopPublishing: function() {
-      // Unregister listeners for publisher events
-      this.stopListening(this.publisher);
+      if (this.publisher) {
+        // Unregister listeners for publisher events
+        this.stopListening(this.publisher);
 
-      this.props.model.session.unpublish(this.publisher);
+        this.props.model.session.unpublish(this.publisher);
+      }
     },
 
     render: function() {
+      var localStreamClasses = React.addons.classSet({
+        local: true,
+        "local-stream": true,
+        "local-stream-audio": !this.state.video.enabled
+      });
       /* jshint ignore:start */
       return (
         React.DOM.div({className: "video-layout-wrapper"}, 
           React.DOM.div({className: "conversation"}, 
-            ConversationToolbar({video: this.state.video, 
-                                 audio: this.state.audio, 
-                                 publishStream: this.publishStream, 
-                                 hangup: this.hangup}), 
             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: "local"})
-            )
+              React.DOM.div({className: localStreamClasses})
+            ), 
+            ConversationToolbar({video: this.state.video, 
+                                 audio: this.state.audio, 
+                                 publishStream: this.publishStream, 
+                                 hangup: this.hangup})
           )
         )
       );
       /* jshint ignore:end */
     }
   });
 
   /**
@@ -378,17 +342,18 @@ loop.shared.views = (function(_, OT, l10
       title: React.PropTypes.string.isRequired,
       reset: React.PropTypes.func // if not specified, no Back btn is shown
     },
 
     render: function() {
       var backButton = React.DOM.div(null);
       if (this.props.reset) {
         backButton = (
-          React.DOM.button({className: "back", type: "button", onClick: this.props.reset}, 
+          React.DOM.button({className: "fx-embedded-btn-back", type: "button", 
+                  onClick: this.props.reset}, 
             "« ", l10n.get("feedback_back_button")
           )
         );
       }
       return (
         React.DOM.div({className: "feedback"}, 
           backButton, 
           React.DOM.h3(null, this.props.title), 
@@ -407,17 +372,17 @@ loop.shared.views = (function(_, OT, l10
       sendFeedback: React.PropTypes.func,
       reset:        React.PropTypes.func
     },
 
     getInitialState: function() {
       return {category: "", description: ""};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {pending: false};
     },
 
     _getCategories: function() {
       return {
         audio_quality: l10n.get("feedback_category_audio_quality"),
         video_quality: l10n.get("feedback_category_video_quality"),
         disconnected : l10n.get("feedback_category_was_disconnected"),
@@ -512,18 +477,26 @@ loop.shared.views = (function(_, OT, l10
           )
         )
       );
     }
   });
 
   /**
    * Feedback received view.
+   *
+   * Props:
+   * - {Function} onAfterFeedbackReceived Function to execute after the
+   *   WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS timeout has elapsed
    */
   var FeedbackReceived = React.createClass({displayName: 'FeedbackReceived',
+    propTypes: {
+      onAfterFeedbackReceived: React.PropTypes.func
+    },
+
     getInitialState: function() {
       return {countdown: WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS};
     },
 
     componentDidMount: function() {
       this._timer = setInterval(function() {
         this.setState({countdown: this.state.countdown - 1});
       }.bind(this), 1000);
@@ -533,17 +506,19 @@ loop.shared.views = (function(_, OT, l10
       if (this._timer) {
         clearInterval(this._timer);
       }
     },
 
     render: function() {
       if (this.state.countdown < 1) {
         clearInterval(this._timer);
-        window.close();
+        if (this.props.onAfterFeedbackReceived) {
+          this.props.onAfterFeedbackReceived();
+        }
       }
       return (
         FeedbackLayout({title: l10n.get("feedback_thank_you_heading")}, 
           React.DOM.p({className: "info thank-you"}, 
             l10n.get("feedback_window_will_close_in2", {
               countdown: this.state.countdown,
               num: this.state.countdown
             }))
@@ -554,25 +529,26 @@ loop.shared.views = (function(_, OT, l10
 
   /**
    * Feedback view.
    */
   var FeedbackView = React.createClass({displayName: 'FeedbackView',
     propTypes: {
       // A loop.FeedbackAPIClient instance
       feedbackApiClient: React.PropTypes.object.isRequired,
+      onAfterFeedbackReceived: React.PropTypes.func,
       // The current feedback submission flow step name
       step: React.PropTypes.oneOf(["start", "form", "finished"])
     },
 
     getInitialState: function() {
       return {pending: false, step: this.props.step || "start"};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {step: "start"};
     },
 
     reset: function() {
       this.setState(this.getInitialState());
     },
 
     handleHappyClick: function() {
@@ -597,17 +573,20 @@ loop.shared.views = (function(_, OT, l10
         console.error("Unable to send user feedback", err);
       }
       this.setState({pending: false, step: "finished"});
     },
 
     render: function() {
       switch(this.state.step) {
         case "finished":
-          return FeedbackReceived(null);
+          return (
+            FeedbackReceived({
+              onAfterFeedbackReceived: this.props.onAfterFeedbackReceived})
+          );
         case "form":
           return FeedbackForm({feedbackApiClient: this.props.feedbackApiClient, 
                                sendFeedback: this.sendFeedback, 
                                reset: this.reset, 
                                pending: this.state.pending});
         default:
           return (
             FeedbackLayout({title: 
@@ -622,179 +601,146 @@ loop.shared.views = (function(_, OT, l10
           );
       }
     }
   });
 
   /**
    * Notification view.
    */
-  var NotificationView = BaseView.extend({
-    template: _.template([
-      '<div class="alert alert-<%- level %>">',
-      '  <button class="close"></button>',
-      '  <p class="message"><%- message %></p>',
-      '</div>'
-    ].join("")),
+  var NotificationView = React.createClass({displayName: 'NotificationView',
+    mixins: [Backbone.Events],
 
-    events: {
-      "click .close": "dismiss"
-    },
-
-    dismiss: function(event) {
-      event.preventDefault();
-      this.$el.addClass("fade-out");
-      setTimeout(function() {
-        this.collection.remove(this.model);
-        this.remove();
-      }.bind(this), 500); // XXX make timeout value configurable
+    propTypes: {
+      notification: React.PropTypes.object.isRequired,
+      key: React.PropTypes.number.isRequired
     },
 
     render: function() {
-      this.$el.html(this.template(this.model.toJSON()));
-      return this;
+      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"))
+        )
+      );
     }
   });
 
   /**
    * Notification list view.
    */
-  var NotificationListView = Backbone.View.extend({
-    /**
-     * Constructor.
-     *
-     * Available options:
-     * - {loop.shared.models.NotificationCollection} collection Notifications
-     *                                                          collection
-     *
-     * @param  {Object} options Options object
-     */
-    initialize: function(options) {
-      options = options || {};
-      if (!options.collection) {
-        this.collection = new sharedModels.NotificationCollection();
-      }
-      this.listenTo(this.collection, "reset add remove", this.render);
-    },
+  var NotificationListView = React.createClass({displayName: 'NotificationListView',
+    mixins: [Backbone.Events, sharedMixins.DocumentVisibilityMixin],
 
-    /**
-     * Clears the notification stack.
-     */
-    clear: function() {
-      this.collection.reset();
+    propTypes: {
+      notifications: React.PropTypes.object.isRequired,
+      clearOnDocumentHidden: React.PropTypes.bool
     },
 
-    /**
-     * Adds a new notification to the stack, triggering rendering of it.
-     *
-     * @param  {Object|NotificationModel} notification Notification data.
-     */
-    notify: function(notification) {
-      this.collection.add(notification);
+    getDefaultProps: function() {
+      return {clearOnDocumentHidden: false};
     },
 
-    /**
-     * Adds a new notification to the stack using an l10n message identifier,
-     * triggering rendering of it.
-     *
-     * @param  {String} messageId L10n message id
-     * @param  {String} level     Notification level
-     */
-    notifyL10n: function(messageId, level) {
-      this.notify({
-        message: l10n.get(messageId),
-        level: level
-      });
+    componentDidMount: function() {
+      this.listenTo(this.props.notifications, "reset add remove", function() {
+        this.forceUpdate();
+      }.bind(this));
     },
 
-    /**
-     * Adds a warning notification to the stack and renders it.
-     *
-     * @return {String} message
-     */
-    warn: function(message) {
-      this.notify({level: "warning", message: message});
+    componentWillUnmount: function() {
+      this.stopListening(this.props.notifications);
     },
 
     /**
-     * Adds a l10n warning notification to the stack and renders it.
-     *
-     * @param  {String} messageId L10n message id
+     * Provided by DocumentVisibilityMixin. Clears notifications stack when the
+     * current document is hidden if the clearOnDocumentHidden prop is set to
+     * true and the collection isn't empty.
      */
-    warnL10n: function(messageId) {
-      this.warn(l10n.get(messageId));
-    },
-
-    /**
-     * Adds an error notification to the stack and renders it.
-     *
-     * @return {String} message
-     */
-    error: function(message) {
-      this.notify({level: "error", message: message});
+    onDocumentHidden: function() {
+      if (this.props.clearOnDocumentHidden &&
+          this.props.notifications.length > 0) {
+        // Note: The `silent` option prevents the `reset` event to be triggered
+        // here, preventing the UI to "jump" a little because of the event
+        // callback being processed in another tick (I think).
+        this.props.notifications.reset([], {silent: true});
+        this.forceUpdate();
+      }
     },
 
-    /**
-     * Adds a l10n rror notification to the stack and renders it.
-     *
-     * @param  {String} messageId L10n message id
-     */
-    errorL10n: function(messageId) {
-      this.error(l10n.get(messageId));
-    },
-
-    /**
-     * Renders this view.
-     *
-     * @return {loop.shared.views.NotificationListView}
-     */
     render: function() {
-      this.$el.html(this.collection.map(function(notification) {
-        return new NotificationView({
-          model: notification,
-          collection: this.collection
-        }).render().$el;
-      }.bind(this)));
-      return this;
+      return (
+        React.DOM.div({className: "messages"}, 
+          this.props.notifications.map(function(notification, key) {
+            return NotificationView({key: key, notification: notification});
+          })
+        
+        )
+      );
     }
   });
 
-  /**
-   * Unsupported Browsers view.
-   */
-  var UnsupportedBrowserView = BaseView.extend({
-    template: _.template([
-      '<div>',
-      '  <h2 data-l10n-id="incompatible_browser"></h2>',
-      '  <p data-l10n-id="powered_by_webrtc"></p>',
-      '  <p data-l10n-id="use_latest_firefox" ',
-      '    data-l10n-args=\'{"ff_url": "https://www.mozilla.org/firefox/"}\'>',
-      '  </p>',
-      '</div>'
-    ].join(""))
+  var Button = React.createClass({displayName: 'Button',
+    propTypes: {
+      caption: React.PropTypes.string.isRequired,
+      onClick: React.PropTypes.func.isRequired,
+      disabled: React.PropTypes.bool,
+      additionalClass: React.PropTypes.string,
+    },
+
+    getDefaultProps: function() {
+      return {
+        disabled: false,
+        additionalClass: "",
+      };
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var classObject = { button: true, disabled: this.props.disabled };
+      if (this.props.additionalClass) {
+        classObject[this.props.additionalClass] = true;
+      }
+      return (
+        React.DOM.button({onClick: this.props.onClick, 
+                disabled: this.props.disabled, 
+                className: cx(classObject)}, 
+          this.props.caption
+        )
+      )
+    }
   });
 
-  /**
-   * Unsupported Browsers view.
-   */
-  var UnsupportedDeviceView = BaseView.extend({
-    template: _.template([
-      '<div>',
-      '  <h2 data-l10n-id="incompatible_device"></h2>',
-      '  <p data-l10n-id="sorry_device_unsupported"></p>',
-      '  <p data-l10n-id="use_firefox_windows_mac_linux"></p>',
-      '</div>'
-    ].join(""))
+  var ButtonGroup = React.createClass({displayName: 'ButtonGroup',
+    PropTypes: {
+      additionalClass: React.PropTypes.string
+    },
+
+    getDefaultProps: function() {
+      return {
+        additionalClass: "",
+      };
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var classObject = { "button-group": true };
+      if (this.props.additionalClass) {
+        classObject[this.props.additionalClass] = true;
+      }
+      return (
+        React.DOM.div({className: cx(classObject)}, 
+          this.props.children
+        )
+      )
+    }
   });
 
   return {
-    L10nView: L10nView,
-    BaseView: BaseView,
+    Button: Button,
+    ButtonGroup: ButtonGroup,
     ConversationView: ConversationView,
     ConversationToolbar: ConversationToolbar,
     FeedbackView: FeedbackView,
     MediaControlButton: MediaControlButton,
-    NotificationListView: NotificationListView,
-    NotificationView: NotificationView,
-    UnsupportedBrowserView: UnsupportedBrowserView,
-    UnsupportedDeviceView: UnsupportedDeviceView
+    NotificationListView: NotificationListView
   };
-})(_, window.OT, document.webL10n || document.mozL10n);
+})(_, window.OT, navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -7,128 +7,55 @@
 /* jshint newcap:false */
 /* global loop:true, React */
 var loop = loop || {};
 loop.shared = loop.shared || {};
 loop.shared.views = (function(_, OT, l10n) {
   "use strict";
 
   var sharedModels = loop.shared.models;
-  var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
-
-  /**
-   * L10n view. Translates resulting view DOM fragment once rendered.
-   */
-  var L10nView = (function() {
-    var L10nViewImpl   = Backbone.View.extend(), // Original View constructor
-        originalExtend = L10nViewImpl.extend;    // Original static extend fn
-
-    /**
-     * Patches View extend() method so we can hook and patch any declared render
-     * method.
-     *
-     * @return {Backbone.View} Extended view with patched render() method.
-     */
-    L10nViewImpl.extend = function() {
-      var ExtendedView   = originalExtend.apply(this, arguments),
-          originalRender = ExtendedView.prototype.render;
-
-      /**
-       * Wraps original render() method to translate contents once they're
-       * rendered.
-       *
-       * @return {Backbone.View} Extended view instance.
-       */
-      ExtendedView.prototype.render = function() {
-        if (originalRender) {
-          originalRender.apply(this, arguments);
-          l10n.translate(this.el);
-        }
-        return this;
-      };
-
-      return ExtendedView;
-    };
-
-    return L10nViewImpl;
-  })();
+  var sharedMixins = loop.shared.mixins;
 
-  /**
-   * Base view.
-   */
-  var BaseView = L10nView.extend({
-    /**
-     * Hides view element.
-     *
-     * @return {BaseView}
-     */
-    hide: function() {
-      this.$el.hide();
-      return this;
-    },
-
-    /**
-     * Shows view element.
-     *
-     * @return {BaseView}
-     */
-    show: function() {
-      this.$el.show();
-      return this;
-    },
-
-    /**
-     * Base render implementation: renders an attached template if available.
-     *
-     * Note: You need to override this if you want to do fancier stuff, eg.
-     *       rendering the template using model data.
-     *
-     * @return {BaseView}
-     */
-    render: function() {
-      if (this.template) {
-        this.$el.html(this.template());
-      }
-      return this;
-    }
-  });
+  var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
 
   /**
    * Media control button.
    *
    * Required props:
    * - {String}   scope   Media scope, can be "local" or "remote".
    * - {String}   type    Media type, can be "audio" or "video".
    * - {Function} action  Function to be executed on click.
    * - {Enabled}  enabled Stream activation status (default: true).
    */
   var MediaControlButton = React.createClass({
     propTypes: {
       scope: React.PropTypes.string.isRequired,
       type: React.PropTypes.string.isRequired,
       action: React.PropTypes.func.isRequired,
-      enabled: React.PropTypes.bool.isRequired
+      enabled: React.PropTypes.bool.isRequired,
+      visible: React.PropTypes.bool.isRequired
     },
 
     getDefaultProps: function() {
-      return {enabled: true};
+      return {enabled: true, visible: true};
     },
 
     handleClick: function() {
       this.props.action();
     },
 
     _getClasses: function() {
       var cx = React.addons.classSet;
       // classes
       var classesObj = {
         "btn": true,
         "media-control": true,
         "local-media": this.props.scope === "local",
-        "muted": !this.props.enabled
+        "muted": !this.props.enabled,
+        "hide": !this.props.visible
       };
       classesObj["btn-mute-" + this.props.type] = true;
       return cx(classesObj);
     },
 
     _getTitle: function(enabled) {
       var prefix = this.props.enabled ? "mute" : "unmute";
       var suffix = "button_title";
@@ -148,18 +75,18 @@ loop.shared.views = (function(_, OT, l10
   });
 
   /**
    * Conversation controls.
    */
   var ConversationToolbar = React.createClass({
     getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     propTypes: {
       video: React.PropTypes.object.isRequired,
       audio: React.PropTypes.object.isRequired,
       hangup: React.PropTypes.func.isRequired,
       publishStream: React.PropTypes.func.isRequired
@@ -173,91 +100,121 @@ loop.shared.views = (function(_, OT, l10
       this.props.publishStream("video", !this.props.video.enabled);
     },
 
     handleToggleAudio: function() {
       this.props.publishStream("audio", !this.props.audio.enabled);
     },
 
     render: function() {
-      /* jshint ignore:start */
+      var cx = React.addons.classSet;
       return (
         <ul className="conversation-toolbar">
           <li className="conversation-toolbar-btn-box">
             <button className="btn btn-hangup" onClick={this.handleClickHangup}
                     title={l10n.get("hangup_button_title")}>
               {l10n.get("hangup_button_caption2")}
             </button>
           </li>
           <li className="conversation-toolbar-btn-box">
             <MediaControlButton action={this.handleToggleVideo}
                                 enabled={this.props.video.enabled}
+                                visible={this.props.video.visible}
                                 scope="local" type="video" />
           </li>
           <li className="conversation-toolbar-btn-box">
             <MediaControlButton action={this.handleToggleAudio}
                                 enabled={this.props.audio.enabled}
+                                visible={this.props.audio.visible}
                                 scope="local" type="audio" />
           </li>
         </ul>
       );
-      /* jshint ignore:end */
     }
   });
 
+  /**
+   * Conversation view.
+   */
   var ConversationView = React.createClass({
     mixins: [Backbone.Events],
 
     propTypes: {
       sdk: React.PropTypes.object.isRequired,
-      model: React.PropTypes.object.isRequired
+      video: React.PropTypes.object,
+      audio: React.PropTypes.object,
+      initiate: React.PropTypes.bool
     },
 
     // height set to 100%" to fix video layout on Google Chrome
     // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
     publisherConfig: {
       insertMode: "append",
       width: "100%",
       height: "100%",
       style: {
         bugDisplayMode: "off",
         buttonDisplayMode: "off",
         nameDisplayMode: "off"
       }
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        initiate: true,
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     getInitialState: function() {
       return {
         video: this.props.video,
         audio: this.props.audio
       };
     },
 
     componentWillMount: function() {
-      this.publisherConfig.publishVideo = this.props.video.enabled;
+      if (this.props.initiate) {
+        this.publisherConfig.publishVideo = this.props.video.enabled;
+      }
     },
 
     componentDidMount: function() {
-      this.listenTo(this.props.model, "session:connected",
-                                      this.startPublishing);
-      this.listenTo(this.props.model, "session:stream-created",
-                                      this._streamCreated);
-      this.listenTo(this.props.model, ["session:peer-hungup",
-                                       "session:network-disconnected",
-                                       "session:ended"].join(" "),
-                                       this.stopPublishing);
+      if (this.props.initiate) {
+        this.listenTo(this.props.model, "session:connected",
+                                        this.startPublishing);
+        this.listenTo(this.props.model, "session:stream-created",
+                                        this._streamCreated);
+        this.listenTo(this.props.model, ["session:peer-hungup",
+                                         "session:network-disconnected",
+                                         "session:ended"].join(" "),
+                                         this.stopPublishing);
+        this.props.model.startSession();
+      }
 
-      this.props.model.startSession();
+      /**
+       * 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);
+    },
+
+    updateVideoContainer: function() {
+      var localStreamParent = document.querySelector('.local .OT_publisher');
+      var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
     },
 
     componentWillUnmount: function() {
       // Unregister all local event listeners
       this.stopListening();
       this.hangup();
     },
 
@@ -333,37 +290,44 @@ loop.shared.views = (function(_, OT, l10
         this.setState({video: {enabled: enabled}});
       }
     },
 
     /**
      * Unpublishes local stream.
      */
     stopPublishing: function() {
-      // Unregister listeners for publisher events
-      this.stopListening(this.publisher);
+      if (this.publisher) {
+        // Unregister listeners for publisher events
+        this.stopListening(this.publisher);
 
-      this.props.model.session.unpublish(this.publisher);
+        this.props.model.session.unpublish(this.publisher);
+      }
     },
 
     render: function() {
+      var localStreamClasses = React.addons.classSet({
+        local: true,
+        "local-stream": true,
+        "local-stream-audio": !this.state.video.enabled
+      });
       /* jshint ignore:start */
       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>
             <ConversationToolbar video={this.state.video}
                                  audio={this.state.audio}
                                  publishStream={this.publishStream}
                                  hangup={this.hangup} />
-            <div className="media nested">
-              <div className="video_wrapper remote_wrapper">
-                <div className="video_inner remote"></div>
-              </div>
-              <div className="local"></div>
-            </div>
           </div>
         </div>
       );
       /* jshint ignore:end */
     }
   });
 
   /**
@@ -378,17 +342,18 @@ loop.shared.views = (function(_, OT, l10
       title: React.PropTypes.string.isRequired,
       reset: React.PropTypes.func // if not specified, no Back btn is shown
     },
 
     render: function() {
       var backButton = <div />;
       if (this.props.reset) {
         backButton = (
-          <button className="back" type="button" onClick={this.props.reset}>
+          <button className="fx-embedded-btn-back" type="button"
+                  onClick={this.props.reset}>
             &laquo;&nbsp;{l10n.get("feedback_back_button")}
           </button>
         );
       }
       return (
         <div className="feedback">
           {backButton}
           <h3>{this.props.title}</h3>
@@ -407,17 +372,17 @@ loop.shared.views = (function(_, OT, l10
       sendFeedback: React.PropTypes.func,
       reset:        React.PropTypes.func
     },
 
     getInitialState: function() {
       return {category: "", description: ""};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {pending: false};
     },
 
     _getCategories: function() {
       return {
         audio_quality: l10n.get("feedback_category_audio_quality"),
         video_quality: l10n.get("feedback_category_video_quality"),
         disconnected : l10n.get("feedback_category_was_disconnected"),
@@ -512,18 +477,26 @@ loop.shared.views = (function(_, OT, l10
           </form>
         </FeedbackLayout>
       );
     }
   });
 
   /**
    * Feedback received view.
+   *
+   * Props:
+   * - {Function} onAfterFeedbackReceived Function to execute after the
+   *   WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS timeout has elapsed
    */
   var FeedbackReceived = React.createClass({
+    propTypes: {
+      onAfterFeedbackReceived: React.PropTypes.func
+    },
+
     getInitialState: function() {
       return {countdown: WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS};
     },
 
     componentDidMount: function() {
       this._timer = setInterval(function() {
         this.setState({countdown: this.state.countdown - 1});
       }.bind(this), 1000);
@@ -533,17 +506,19 @@ loop.shared.views = (function(_, OT, l10
       if (this._timer) {
         clearInterval(this._timer);
       }
     },
 
     render: function() {
       if (this.state.countdown < 1) {
         clearInterval(this._timer);
-        window.close();
+        if (this.props.onAfterFeedbackReceived) {
+          this.props.onAfterFeedbackReceived();
+        }
       }
       return (
         <FeedbackLayout title={l10n.get("feedback_thank_you_heading")}>
           <p className="info thank-you">{
             l10n.get("feedback_window_will_close_in2", {
               countdown: this.state.countdown,
               num: this.state.countdown
             })}</p>
@@ -554,25 +529,26 @@ loop.shared.views = (function(_, OT, l10
 
   /**
    * Feedback view.
    */
   var FeedbackView = React.createClass({
     propTypes: {
       // A loop.FeedbackAPIClient instance
       feedbackApiClient: React.PropTypes.object.isRequired,
+      onAfterFeedbackReceived: React.PropTypes.func,
       // The current feedback submission flow step name
       step: React.PropTypes.oneOf(["start", "form", "finished"])
     },
 
     getInitialState: function() {
       return {pending: false, step: this.props.step || "start"};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {step: "start"};
     },
 
     reset: function() {
       this.setState(this.getInitialState());
     },
 
     handleHappyClick: function() {
@@ -597,17 +573,20 @@ loop.shared.views = (function(_, OT, l10
         console.error("Unable to send user feedback", err);
       }
       this.setState({pending: false, step: "finished"});
     },
 
     render: function() {
       switch(this.state.step) {
         case "finished":
-          return <FeedbackReceived />;
+          return (
+            <FeedbackReceived
+              onAfterFeedbackReceived={this.props.onAfterFeedbackReceived} />
+          );
         case "form":
           return <FeedbackForm feedbackApiClient={this.props.feedbackApiClient}
                                sendFeedback={this.sendFeedback}
                                reset={this.reset}
                                pending={this.state.pending} />;
         default:
           return (
             <FeedbackLayout title={
@@ -622,179 +601,146 @@ loop.shared.views = (function(_, OT, l10
           );
       }
     }
   });
 
   /**
    * Notification view.
    */
-  var NotificationView = BaseView.extend({
-    template: _.template([
-      '<div class="alert alert-<%- level %>">',
-      '  <button class="close"></button>',
-      '  <p class="message"><%- message %></p>',
-      '</div>'
-    ].join("")),
+  var NotificationView = React.createClass({
+    mixins: [Backbone.Events],
 
-    events: {
-      "click .close": "dismiss"
-    },
-
-    dismiss: function(event) {
-      event.preventDefault();
-      this.$el.addClass("fade-out");
-      setTimeout(function() {
-        this.collection.remove(this.model);
-        this.remove();
-      }.bind(this), 500); // XXX make timeout value configurable
+    propTypes: {
+      notification: React.PropTypes.object.isRequired,
+      key: React.PropTypes.number.isRequired
     },
 
     render: function() {
-      this.$el.html(this.template(this.model.toJSON()));
-      return this;
+      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>
+      );
     }
   });
 
   /**
    * Notification list view.
    */
-  var NotificationListView = Backbone.View.extend({
-    /**
-     * Constructor.
-     *
-     * Available options:
-     * - {loop.shared.models.NotificationCollection} collection Notifications
-     *                                                          collection
-     *
-     * @param  {Object} options Options object
-     */
-    initialize: function(options) {
-      options = options || {};
-      if (!options.collection) {
-        this.collection = new sharedModels.NotificationCollection();
-      }
-      this.listenTo(this.collection, "reset add remove", this.render);
-    },
+  var NotificationListView = React.createClass({
+    mixins: [Backbone.Events, sharedMixins.DocumentVisibilityMixin],
 
-    /**
-     * Clears the notification stack.
-     */
-    clear: function() {
-      this.collection.reset();
+    propTypes: {
+      notifications: React.PropTypes.object.isRequired,
+      clearOnDocumentHidden: React.PropTypes.bool
     },
 
-    /**
-     * Adds a new notification to the stack, triggering rendering of it.
-     *
-     * @param  {Object|NotificationModel} notification Notification data.
-     */
-    notify: function(notification) {
-      this.collection.add(notification);
+    getDefaultProps: function() {
+      return {clearOnDocumentHidden: false};
     },
 
-    /**
-     * Adds a new notification to the stack using an l10n message identifier,
-     * triggering rendering of it.
-     *
-     * @param  {String} messageId L10n message id
-     * @param  {String} level     Notification level
-     */
-    notifyL10n: function(messageId, level) {
-      this.notify({
-        message: l10n.get(messageId),
-        level: level
-      });
+    componentDidMount: function() {
+      this.listenTo(this.props.notifications, "reset add remove", function() {
+        this.forceUpdate();
+      }.bind(this));
     },
 
-    /**
-     * Adds a warning notification to the stack and renders it.
-     *
-     * @return {String} message
-     */
-    warn: function(message) {
-      this.notify({level: "warning", message: message});
+    componentWillUnmount: function() {
+      this.stopListening(this.props.notifications);
     },
 
     /**
-     * Adds a l10n warning notification to the stack and renders it.
-     *
-     * @param  {String} messageId L10n message id
+     * Provided by DocumentVisibilityMixin. Clears notifications stack when the
+     * current document is hidden if the clearOnDocumentHidden prop is set to
+     * true and the collection isn't empty.
      */
-    warnL10n: function(messageId) {
-      this.warn(l10n.get(messageId));
-    },
-
-    /**
-     * Adds an error notification to the stack and renders it.
-     *
-     * @return {String} message
-     */
-    error: function(message) {
-      this.notify({level: "error", message: message});
+    onDocumentHidden: function() {
+      if (this.props.clearOnDocumentHidden &&
+          this.props.notifications.length > 0) {
+        // Note: The `silent` option prevents the `reset` event to be triggered
+        // here, preventing the UI to "jump" a little because of the event
+        // callback being processed in another tick (I think).
+        this.props.notifications.reset([], {silent: true});
+        this.forceUpdate();
+      }
     },
 
-    /**
-     * Adds a l10n rror notification to the stack and renders it.
-     *
-     * @param  {String} messageId L10n message id
-     */
-    errorL10n: function(messageId) {
-      this.error(l10n.get(messageId));
-    },
-
-    /**
-     * Renders this view.
-     *
-     * @return {loop.shared.views.NotificationListView}
-     */
     render: function() {
-      this.$el.html(this.collection.map(function(notification) {
-        return new NotificationView({
-          model: notification,
-          collection: this.collection
-        }).render().$el;
-      }.bind(this)));
-      return this;
+      return (
+        <div className="messages">{
+          this.props.notifications.map(function(notification, key) {
+            return <NotificationView key={key} notification={notification}/>;
+          })
+        }
+        </div>
+      );
     }
   });
 
-  /**
-   * Unsupported Browsers view.
-   */
-  var UnsupportedBrowserView = BaseView.extend({
-    template: _.template([
-      '<div>',
-      '  <h2 data-l10n-id="incompatible_browser"></h2>',
-      '  <p data-l10n-id="powered_by_webrtc"></p>',
-      '  <p data-l10n-id="use_latest_firefox" ',
-      '    data-l10n-args=\'{"ff_url": "https://www.mozilla.org/firefox/"}\'>',
-      '  </p>',
-      '</div>'
-    ].join(""))
+  var Button = React.createClass({
+    propTypes: {
+      caption: React.PropTypes.string.isRequired,
+      onClick: React.PropTypes.func.isRequired,
+      disabled: React.PropTypes.bool,
+      additionalClass: React.PropTypes.string,
+    },
+
+    getDefaultProps: function() {
+      return {
+        disabled: false,
+        additionalClass: "",
+      };
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var classObject = { button: true, disabled: this.props.disabled };
+      if (this.props.additionalClass) {
+        classObject[this.props.additionalClass] = true;
+      }
+      return (
+        <button onClick={this.props.onClick}
+                disabled={this.props.disabled}
+                className={cx(classObject)}>
+          {this.props.caption}
+        </button>
+      )
+    }
   });
 
-  /**
-   * Unsupported Browsers view.
-   */
-  var UnsupportedDeviceView = BaseView.extend({
-    template: _.template([
-      '<div>',
-      '  <h2 data-l10n-id="incompatible_device"></h2>',
-      '  <p data-l10n-id="sorry_device_unsupported"></p>',
-      '  <p data-l10n-id="use_firefox_windows_mac_linux"></p>',
-      '</div>'
-    ].join(""))
+  var ButtonGroup = React.createClass({
+    PropTypes: {
+      additionalClass: React.PropTypes.string
+    },
+
+    getDefaultProps: function() {
+      return {
+        additionalClass: "",
+      };
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var classObject = { "button-group": true };
+      if (this.props.additionalClass) {
+        classObject[this.props.additionalClass] = true;
+      }
+      return (
+        <div className={cx(classObject)}>
+          {this.props.children}
+        </div>
+      )
+    }
   });
 
   return {
-    L10nView: L10nView,
-    BaseView: BaseView,
+    Button: Button,
+    ButtonGroup: ButtonGroup,
     ConversationView: ConversationView,
     ConversationToolbar: ConversationToolbar,
     FeedbackView: FeedbackView,
     MediaControlButton: MediaControlButton,
-    NotificationListView: NotificationListView,
-    NotificationView: NotificationView,
-    UnsupportedBrowserView: UnsupportedBrowserView,
-    UnsupportedDeviceView: UnsupportedDeviceView
+    NotificationListView: NotificationListView
   };
-})(_, window.OT, document.webL10n || document.mozL10n);
+})(_, window.OT, navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/content/shared/js/websocket.js
+++ b/browser/components/loop/content/shared/js/websocket.js
@@ -31,21 +31,22 @@ loop.CallConnectionWebSocket = (function
     }
     if (!this.options.callId) {
       throw new Error("No callId in options");
     }
     if (!this.options.websocketToken) {
       throw new Error("No websocketToken in options");
     }
 
-    // Save the debug pref now, to avoid getting it each time.
-    if (navigator.mozLoop) {
-      this._debugWebSocket =
-        navigator.mozLoop.getLoopBoolPref("debug.websocket");
-    }
+    this._lastServerState = "init";
+
+    // Set loop.debug.sdk to true in the browser, or standalone:
+    // localStorage.setItem("debug.websocket", true);
+    this._debugWebSocket =
+      loop.shared.utils.getBoolPreference("debug.websocket");
 
     _.extend(this, Backbone.Events);
   };
 
   CallConnectionWebSocket.prototype = {
     /**
      * Start the connection to the websocket.
      *
@@ -74,16 +75,26 @@ loop.CallConnectionWebSocket = (function
             reject: reject,
             timeout: timeout
           };
         }.bind(this));
 
       return promise;
     },
 
+    /**
+     * Closes the websocket. This shouldn't be the normal action as the server
+     * will normally close the socket. Only in bad error cases, or where we need
+     * to close the socket just before closing the window (to avoid an error)
+     * should we call this.
+     */
+    close: function() {
+      this.socket.close();
+    },
+
     _clearConnectionFlags: function() {
       clearTimeout(this.connectDetails.timeout);
       delete this.connectDetails;
     },
 
     /**
      * Internal function called to resolve the connection promise.
      *
@@ -144,16 +155,28 @@ loop.CallConnectionWebSocket = (function
     mediaUp: function() {
       this._send({
         messageType: "action",
         event: "media-up"
       });
     },
 
     /**
+     * Notifies the server that the outgoing call is cancelled by the
+     * user.
+     */
+    cancel: function() {
+      this._send({
+        messageType: "action",
+        event: "terminate",
+        reason: "cancel"
+      });
+    },
+
+    /**
      * 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));
@@ -194,24 +217,26 @@ loop.CallConnectionWebSocket = (function
         msg = JSON.parse(event.data);
       } catch (x) {
         console.error("Error parsing received message:", x);
         return;
       }
 
       this._log("WS Receiving", event.data);
 
+      var previousState = this._lastServerState;
       this._lastServerState = msg.state;
 
       switch(msg.messageType) {
         case "hello":
           this._completeConnection();
           break;
         case "progress":
-          this.trigger("progress", msg);
+          this.trigger("progress:" + msg.state);
+          this.trigger("progress", msg, previousState);
           break;
       }
     },
 
     /**
      * Called when there is an error on the websocket.
      *
      * @param {Object} event A simple error event.
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/libs/react-0.11.1-prod.js
@@ -0,0 +1,22 @@
+/**
+ * React (with addons) v0.11.1
+ *
+ * Copyright 2013-2014 Facebook, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var t;"undefined"!=typeof window?t=window:"undefined"!=typeof global?t=global:"undefined"!=typeof self&&(t=self),t.React=e()}}(function(){return function e(t,n,r){function o(a,s){if(!n[a]){if(!t[a]){var u="function"==typeof require&&require;if(!s&&u)return u(a,!0);if(i)return i(a,!0);throw new Error("Cannot find module '"+a+"'")}var c=n[a]={exports:{}};t[a][0].call(c.exports,function(e){var n=t[a][1][e];return o(n?n:e)},c,c.exports,e,t,n,r)}return n[a].exports}for(var i="function"==typeof require&&require,a=0;a<r.length;a++)o(r[a]);return o}({1:[function(e,t){"use strict";var n=e("./focusNode"),r={componentDidMount:function(){this.props.autoFocus&&n(this.getDOMNode())}};t.exports=r},{"./focusNode":117}],2:[function(e,t){"use strict";function n(){var e=window.opera;return"object"==typeof e&&"function"==typeof e.version&&parseInt(e.version(),10)<=12}function r(e){return(e.ctrlKey||e.altKey||e.metaKey)&&!(e.ctrlKey&&e.altKey)}var o=e("./EventConstants"),i=e("./EventPropagators"),a=e("./ExecutionEnvironment"),s=e("./SyntheticInputEvent"),u=e("./keyOf"),c=a.canUseDOM&&"TextEvent"in window&&!("documentMode"in document||n()),l=32,p=String.fromCharCode(l),d=o.topLevelTypes,f={beforeInput:{phasedRegistrationNames:{bubbled:u({onBeforeInput:null}),captured:u({onBeforeInputCapture:null})},dependencies:[d.topCompositionEnd,d.topKeyPress,d.topTextInput,d.topPaste]}},h=null,v={eventTypes:f,extractEvents:function(e,t,n,o){var a;if(c)switch(e){case d.topKeyPress:var u=o.which;if(u!==l)return;a=String.fromCharCode(u);break;case d.topTextInput:if(a=o.data,a===p)return;break;default:return}else{switch(e){case d.topPaste:h=null;break;case d.topKeyPress:o.which&&!r(o)&&(h=String.fromCharCode(o.which));break;case d.topCompositionEnd:h=o.data}if(null===h)return;a=h}if(a){var v=s.getPooled(f.beforeInput,n,o);return v.data=a,h=null,i.accumulateTwoPhaseDispatches(v),v}}};t.exports=v},{"./EventConstants":16,"./EventPropagators":21,"./ExecutionEnvironment":22,"./SyntheticInputEvent":95,"./keyOf":138}],3:[function(e,t){var n=e("./invariant"),r={addClass:function(e,t){return n(!/\s/.test(t)),t&&(e.classList?e.classList.add(t):r.hasClass(e,t)||(e.className=e.className+" "+t)),e},removeClass:function(e,t){return n(!/\s/.test(t)),t&&(e.classList?e.classList.remove(t):r.hasClass(e,t)&&(e.className=e.className.replace(new RegExp("(^|\\s)"+t+"(?:\\s|$)","g"),"$1").replace(/\s+/g," ").replace(/^\s*|\s*$/g,""))),e},conditionClass:function(e,t,n){return(n?r.addClass:r.removeClass)(e,t)},hasClass:function(e,t){return n(!/\s/.test(t)),e.classList?!!t&&e.classList.contains(t):(" "+e.className+" ").indexOf(" "+t+" ")>-1}};t.exports=r},{"./invariant":131}],4:[function(e,t){"use strict";function n(e,t){return e+t.charAt(0).toUpperCase()+t.substring(1)}var r={columnCount:!0,fillOpacity:!0,flex:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},o=["Webkit","ms","Moz","O"];Object.keys(r).forEach(function(e){o.forEach(function(t){r[n(t,e)]=r[e]})});var i={background:{backgroundImage:!0,backgroundPosition:!0,backgroundRepeat:!0,backgroundColor:!0},border:{borderWidth:!0,borderStyle:!0,borderColor:!0},borderBottom:{borderBottomWidth:!0,borderBottomStyle:!0,borderBottomColor:!0},borderLeft:{borderLeftWidth:!0,borderLeftStyle:!0,borderLeftColor:!0},borderRight:{borderRightWidth:!0,borderRightStyle:!0,borderRightColor:!0},borderTop:{borderTopWidth:!0,borderTopStyle:!0,borderTopColor:!0},font:{fontStyle:!0,fontVariant:!0,fontWeight:!0,fontSize:!0,lineHeight:!0,fontFamily:!0}},a={isUnitlessNumber:r,shorthandPropertyExpansions:i};t.exports=a},{}],5:[function(e,t){"use strict";var n=e("./CSSProperty"),r=e("./dangerousStyleValue"),o=e("./hyphenateStyleName"),i=e("./memoizeStringOnly"),a=i(function(e){return o(e)}),s={createMarkupForStyles:function(e){var t="";for(var n in e)if(e.hasOwnProperty(n)){var o=e[n];null!=o&&(t+=a(n)+":",t+=r(n,o)+";")}return t||null},setValueForStyles:function(e,t){var o=e.style;for(var i in t)if(t.hasOwnProperty(i)){var a=r(i,t[i]);if(a)o[i]=a;else{var s=n.shorthandPropertyExpansions[i];if(s)for(var u in s)o[u]="";else o[i]=""}}}};t.exports=s},{"./CSSProperty":4,"./dangerousStyleValue":112,"./hyphenateStyleName":129,"./memoizeStringOnly":140}],6:[function(e,t){"use strict";function n(){this._callbacks=null,this._contexts=null}var r=e("./PooledClass"),o=e("./invariant"),i=e("./mixInto");i(n,{enqueue:function(e,t){this._callbacks=this._callbacks||[],this._contexts=this._contexts||[],this._callbacks.push(e),this._contexts.push(t)},notifyAll:function(){var e=this._callbacks,t=this._contexts;if(e){o(e.length===t.length),this._callbacks=null,this._contexts=null;for(var n=0,r=e.length;r>n;n++)e[n].call(t[n]);e.length=0,t.length=0}},reset:function(){this._callbacks=null,this._contexts=null},destructor:function(){this.reset()}}),r.addPoolingTo(n),t.exports=n},{"./PooledClass":28,"./invariant":131,"./mixInto":144}],7:[function(e,t){"use strict";function n(e){return"SELECT"===e.nodeName||"INPUT"===e.nodeName&&"file"===e.type}function r(e){var t=M.getPooled(P.change,S,e);C.accumulateTwoPhaseDispatches(t),R.batchedUpdates(o,t)}function o(e){y.enqueueEvents(e),y.processEventQueue()}function i(e,t){T=e,S=t,T.attachEvent("onchange",r)}function a(){T&&(T.detachEvent("onchange",r),T=null,S=null)}function s(e,t,n){return e===O.topChange?n:void 0}function u(e,t,n){e===O.topFocus?(a(),i(t,n)):e===O.topBlur&&a()}function c(e,t){T=e,S=t,w=e.value,_=Object.getOwnPropertyDescriptor(e.constructor.prototype,"value"),Object.defineProperty(T,"value",k),T.attachEvent("onpropertychange",p)}function l(){T&&(delete T.value,T.detachEvent("onpropertychange",p),T=null,S=null,w=null,_=null)}function p(e){if("value"===e.propertyName){var t=e.srcElement.value;t!==w&&(w=t,r(e))}}function d(e,t,n){return e===O.topInput?n:void 0}function f(e,t,n){e===O.topFocus?(l(),c(t,n)):e===O.topBlur&&l()}function h(e){return e!==O.topSelectionChange&&e!==O.topKeyUp&&e!==O.topKeyDown||!T||T.value===w?void 0:(w=T.value,S)}function v(e){return"INPUT"===e.nodeName&&("checkbox"===e.type||"radio"===e.type)}function m(e,t,n){return e===O.topClick?n:void 0}var g=e("./EventConstants"),y=e("./EventPluginHub"),C=e("./EventPropagators"),E=e("./ExecutionEnvironment"),R=e("./ReactUpdates"),M=e("./SyntheticEvent"),D=e("./isEventSupported"),x=e("./isTextInputElement"),b=e("./keyOf"),O=g.topLevelTypes,P={change:{phasedRegistrationNames:{bubbled:b({onChange:null}),captured:b({onChangeCapture:null})},dependencies:[O.topBlur,O.topChange,O.topClick,O.topFocus,O.topInput,O.topKeyDown,O.topKeyUp,O.topSelectionChange]}},T=null,S=null,w=null,_=null,I=!1;E.canUseDOM&&(I=D("change")&&(!("documentMode"in document)||document.documentMode>8));var N=!1;E.canUseDOM&&(N=D("input")&&(!("documentMode"in document)||document.documentMode>9));var k={get:function(){return _.get.call(this)},set:function(e){w=""+e,_.set.call(this,e)}},A={eventTypes:P,extractEvents:function(e,t,r,o){var i,a;if(n(t)?I?i=s:a=u:x(t)?N?i=d:(i=h,a=f):v(t)&&(i=m),i){var c=i(e,t,r);if(c){var l=M.getPooled(P.change,c,o);return C.accumulateTwoPhaseDispatches(l),l}}a&&a(e,t,r)}};t.exports=A},{"./EventConstants":16,"./EventPluginHub":18,"./EventPropagators":21,"./ExecutionEnvironment":22,"./ReactUpdates":84,"./SyntheticEvent":93,"./isEventSupported":132,"./isTextInputElement":134,"./keyOf":138}],8:[function(e,t){"use strict";var n=0,r={createReactRootIndex:function(){return n++}};t.exports=r},{}],9:[function(e,t){"use strict";function n(e){switch(e){case g.topCompositionStart:return C.compositionStart;case g.topCompositionEnd:return C.compositionEnd;case g.topCompositionUpdate:return C.compositionUpdate}}function r(e,t){return e===g.topKeyDown&&t.keyCode===h}function o(e,t){switch(e){case g.topKeyUp:return-1!==f.indexOf(t.keyCode);case g.topKeyDown:return t.keyCode!==h;case g.topKeyPress:case g.topMouseDown:case g.topBlur:return!0;default:return!1}}function i(e){this.root=e,this.startSelection=c.getSelection(e),this.startValue=this.getText()}var a=e("./EventConstants"),s=e("./EventPropagators"),u=e("./ExecutionEnvironment"),c=e("./ReactInputSelection"),l=e("./SyntheticCompositionEvent"),p=e("./getTextContentAccessor"),d=e("./keyOf"),f=[9,13,27,32],h=229,v=u.canUseDOM&&"CompositionEvent"in window,m=!v||"documentMode"in document&&document.documentMode>8&&document.documentMode<=11,g=a.topLevelTypes,y=null,C={compositionEnd:{phasedRegistrationNames:{bubbled:d({onCompositionEnd:null}),captured:d({onCompositionEndCapture:null})},dependencies:[g.topBlur,g.topCompositionEnd,g.topKeyDown,g.topKeyPress,g.topKeyUp,g.topMouseDown]},compositionStart:{phasedRegistrationNames:{bubbled:d({onCompositionStart:null}),captured:d({onCompositionStartCapture:null})},dependencies:[g.topBlur,g.topCompositionStart,g.topKeyDown,g.topKeyPress,g.topKeyUp,g.topMouseDown]},compositionUpdate:{phasedRegistrationNames:{bubbled:d({onCompositionUpdate:null}),captured:d({onCompositionUpdateCapture:null})},dependencies:[g.topBlur,g.topCompositionUpdate,g.topKeyDown,g.topKeyPress,g.topKeyUp,g.topMouseDown]}};i.prototype.getText=function(){return this.root.value||this.root[p()]},i.prototype.getData=function(){var e=this.getText(),t=this.startSelection.start,n=this.startValue.length-this.startSelection.end;return e.substr(t,e.length-n-t)};var E={eventTypes:C,extractEvents:function(e,t,a,u){var c,p;if(v?c=n(e):y?o(e,u)&&(c=C.compositionEnd):r(e,u)&&(c=C.compositionStart),m&&(y||c!==C.compositionStart?c===C.compositionEnd&&y&&(p=y.getData(),y=null):y=new i(t)),c){var d=l.getPooled(c,a,u);return p&&(d.data=p),s.accumulateTwoPhaseDispatches(d),d}}};t.exports=E},{"./EventConstants":16,"./EventPropagators":21,"./ExecutionEnvironment":22,"./ReactInputSelection":61,"./SyntheticCompositionEvent":91,"./getTextContentAccessor":126,"./keyOf":138}],10:[function(e,t){"use strict";function n(e,t,n){e.insertBefore(t,e.childNodes[n]||null)}var r,o=e("./Danger"),i=e("./ReactMultiChildUpdateTypes"),a=e("./getTextContentAccessor"),s=e("./invariant"),u=a();r="textContent"===u?function(e,t){e.textContent=t}:function(e,t){for(;e.firstChild;)e.removeChild(e.firstChild);if(t){var n=e.ownerDocument||document;e.appendChild(n.createTextNode(t))}};var c={dangerouslyReplaceNodeWithMarkup:o.dangerouslyReplaceNodeWithMarkup,updateTextContent:r,processUpdates:function(e,t){for(var a,u=null,c=null,l=0;a=e[l];l++)if(a.type===i.MOVE_EXISTING||a.type===i.REMOVE_NODE){var p=a.fromIndex,d=a.parentNode.childNodes[p],f=a.parentID;s(d),u=u||{},u[f]=u[f]||[],u[f][p]=d,c=c||[],c.push(d)}var h=o.dangerouslyRenderMarkup(t);if(c)for(var v=0;v<c.length;v++)c[v].parentNode.removeChild(c[v]);for(var m=0;a=e[m];m++)switch(a.type){case i.INSERT_MARKUP:n(a.parentNode,h[a.markupIndex],a.toIndex);break;case i.MOVE_EXISTING:n(a.parentNode,u[a.parentID][a.fromIndex],a.toIndex);break;case i.TEXT_CONTENT:r(a.parentNode,a.textContent);break;case i.REMOVE_NODE:}}};t.exports=c},{"./Danger":13,"./ReactMultiChildUpdateTypes":67,"./getTextContentAccessor":126,"./invariant":131}],11:[function(e,t){"use strict";var n=e("./invariant"),r={MUST_USE_ATTRIBUTE:1,MUST_USE_PROPERTY:2,HAS_SIDE_EFFECTS:4,HAS_BOOLEAN_VALUE:8,HAS_NUMERIC_VALUE:16,HAS_POSITIVE_NUMERIC_VALUE:48,HAS_OVERLOADED_BOOLEAN_VALUE:64,injectDOMPropertyConfig:function(e){var t=e.Properties||{},o=e.DOMAttributeNames||{},a=e.DOMPropertyNames||{},s=e.DOMMutationMethods||{};e.isCustomAttribute&&i._isCustomAttributeFunctions.push(e.isCustomAttribute);for(var u in t){n(!i.isStandardName.hasOwnProperty(u)),i.isStandardName[u]=!0;var c=u.toLowerCase();if(i.getPossibleStandardName[c]=u,o.hasOwnProperty(u)){var l=o[u];i.getPossibleStandardName[l]=u,i.getAttributeName[u]=l}else i.getAttributeName[u]=c;i.getPropertyName[u]=a.hasOwnProperty(u)?a[u]:u,i.getMutationMethod[u]=s.hasOwnProperty(u)?s[u]:null;var p=t[u];i.mustUseAttribute[u]=p&r.MUST_USE_ATTRIBUTE,i.mustUseProperty[u]=p&r.MUST_USE_PROPERTY,i.hasSideEffects[u]=p&r.HAS_SIDE_EFFECTS,i.hasBooleanValue[u]=p&r.HAS_BOOLEAN_VALUE,i.hasNumericValue[u]=p&r.HAS_NUMERIC_VALUE,i.hasPositiveNumericValue[u]=p&r.HAS_POSITIVE_NUMERIC_VALUE,i.hasOverloadedBooleanValue[u]=p&r.HAS_OVERLOADED_BOOLEAN_VALUE,n(!i.mustUseAttribute[u]||!i.mustUseProperty[u]),n(i.mustUseProperty[u]||!i.hasSideEffects[u]),n(!!i.hasBooleanValue[u]+!!i.hasNumericValue[u]+!!i.hasOverloadedBooleanValue[u]<=1)}}},o={},i={ID_ATTRIBUTE_NAME:"data-reactid",isStandardName:{},getPossibleStandardName:{},getAttributeName:{},getPropertyName:{},getMutationMethod:{},mustUseAttribute:{},mustUseProperty:{},hasSideEffects:{},hasBooleanValue:{},hasNumericValue:{},hasPositiveNumericValue:{},hasOverloadedBooleanValue:{},_isCustomAttributeFunctions:[],isCustomAttribute:function(e){for(var t=0;t<i._isCustomAttributeFunctions.length;t++){var n=i._isCustomAttributeFunctions[t];if(n(e))return!0}return!1},getDefaultValueForProperty:function(e,t){var n,r=o[e];return r||(o[e]=r={}),t in r||(n=document.createElement(e),r[t]=n[t]),r[t]},injection:r};t.exports=i},{"./invariant":131}],12:[function(e,t){"use strict";function n(e,t){return null==t||r.hasBooleanValue[e]&&!t||r.hasNumericValue[e]&&isNaN(t)||r.hasPositiveNumericValue[e]&&1>t||r.hasOverloadedBooleanValue[e]&&t===!1}var r=e("./DOMProperty"),o=e("./escapeTextForBrowser"),i=e("./memoizeStringOnly"),a=(e("./warning"),i(function(e){return o(e)+'="'})),s={createMarkupForID:function(e){return a(r.ID_ATTRIBUTE_NAME)+o(e)+'"'},createMarkupForProperty:function(e,t){if(r.isStandardName.hasOwnProperty(e)&&r.isStandardName[e]){if(n(e,t))return"";var i=r.getAttributeName[e];return r.hasBooleanValue[e]||r.hasOverloadedBooleanValue[e]&&t===!0?o(i):a(i)+o(t)+'"'}return r.isCustomAttribute(e)?null==t?"":a(e)+o(t)+'"':null},setValueForProperty:function(e,t,o){if(r.isStandardName.hasOwnProperty(t)&&r.isStandardName[t]){var i=r.getMutationMethod[t];if(i)i(e,o);else if(n(t,o))this.deleteValueForProperty(e,t);else if(r.mustUseAttribute[t])e.setAttribute(r.getAttributeName[t],""+o);else{var a=r.getPropertyName[t];r.hasSideEffects[t]&&e[a]===o||(e[a]=o)}}else r.isCustomAttribute(t)&&(null==o?e.removeAttribute(t):e.setAttribute(t,""+o))},deleteValueForProperty:function(e,t){if(r.isStandardName.hasOwnProperty(t)&&r.isStandardName[t]){var n=r.getMutationMethod[t];if(n)n(e,void 0);else if(r.mustUseAttribute[t])e.removeAttribute(r.getAttributeName[t]);else{var o=r.getPropertyName[t],i=r.getDefaultValueForProperty(e.nodeName,o);r.hasSideEffects[t]&&e[o]===i||(e[o]=i)}}else r.isCustomAttribute(t)&&e.removeAttribute(t)}};t.exports=s},{"./DOMProperty":11,"./escapeTextForBrowser":115,"./memoizeStringOnly":140,"./warning":153}],13:[function(e,t){"use strict";function n(e){return e.substring(1,e.indexOf(" "))}var r=e("./ExecutionEnvironment"),o=e("./createNodesFromMarkup"),i=e("./emptyFunction"),a=e("./getMarkupWrap"),s=e("./invariant"),u=/^(<[^ \/>]+)/,c="data-danger-index",l={dangerouslyRenderMarkup:function(e){s(r.canUseDOM);for(var t,l={},p=0;p<e.length;p++)s(e[p]),t=n(e[p]),t=a(t)?t:"*",l[t]=l[t]||[],l[t][p]=e[p];var d=[],f=0;for(t in l)if(l.hasOwnProperty(t)){var h=l[t];for(var v in h)if(h.hasOwnProperty(v)){var m=h[v];h[v]=m.replace(u,"$1 "+c+'="'+v+'" ')}var g=o(h.join(""),i);for(p=0;p<g.length;++p){var y=g[p];y.hasAttribute&&y.hasAttribute(c)&&(v=+y.getAttribute(c),y.removeAttribute(c),s(!d.hasOwnProperty(v)),d[v]=y,f+=1)}}return s(f===d.length),s(d.length===e.length),d},dangerouslyReplaceNodeWithMarkup:function(e,t){s(r.canUseDOM),s(t),s("html"!==e.tagName.toLowerCase());var n=o(t,i)[0];e.parentNode.replaceChild(n,e)}};t.exports=l},{"./ExecutionEnvironment":22,"./createNodesFromMarkup":110,"./emptyFunction":113,"./getMarkupWrap":123,"./invariant":131}],14:[function(e,t){"use strict";var n=e("./keyOf"),r=[n({ResponderEventPlugin:null}),n({SimpleEventPlugin:null}),n({TapEventPlugin:null}),n({EnterLeaveEventPlugin:null}),n({ChangeEventPlugin:null}),n({SelectEventPlugin:null}),n({CompositionEventPlugin:null}),n({BeforeInputEventPlugin:null}),n({AnalyticsEventPlugin:null}),n({MobileSafariClickEventPlugin:null})];t.exports=r},{"./keyOf":138}],15:[function(e,t){"use strict";var n=e("./EventConstants"),r=e("./EventPropagators"),o=e("./SyntheticMouseEvent"),i=e("./ReactMount"),a=e("./keyOf"),s=n.topLevelTypes,u=i.getFirstReactDOM,c={mouseEnter:{registrationName:a({onMouseEnter:null}),dependencies:[s.topMouseOut,s.topMouseOver]},mouseLeave:{registrationName:a({onMouseLeave:null}),dependencies:[s.topMouseOut,s.topMouseOver]}},l=[null,null],p={eventTypes:c,extractEvents:function(e,t,n,a){if(e===s.topMouseOver&&(a.relatedTarget||a.fromElement))return null;if(e!==s.topMouseOut&&e!==s.topMouseOver)return null;var p;if(t.window===t)p=t;else{var d=t.ownerDocument;p=d?d.defaultView||d.parentWindow:window}var f,h;if(e===s.topMouseOut?(f=t,h=u(a.relatedTarget||a.toElement)||p):(f=p,h=t),f===h)return null;var v=f?i.getID(f):"",m=h?i.getID(h):"",g=o.getPooled(c.mouseLeave,v,a);g.type="mouseleave",g.target=f,g.relatedTarget=h;var y=o.getPooled(c.mouseEnter,m,a);return y.type="mouseenter",y.target=h,y.relatedTarget=f,r.accumulateEnterLeaveDispatches(g,y,v,m),l[0]=g,l[1]=y,l}};t.exports=p},{"./EventConstants":16,"./EventPropagators":21,"./ReactMount":65,"./SyntheticMouseEvent":97,"./keyOf":138}],16:[function(e,t){"use strict";var n=e("./keyMirror"),r=n({bubbled:null,captured:null}),o=n({topBlur:null,topChange:null,topClick:null,topCompositionEnd:null,topCompositionStart:null,topCompositionUpdate:null,topContextMenu:null,topCopy:null,topCut:null,topDoubleClick:null,topDrag:null,topDragEnd:null,topDragEnter:null,topDragExit:null,topDragLeave:null,topDragOver:null,topDragStart:null,topDrop:null,topError:null,topFocus:null,topInput:null,topKeyDown:null,topKeyPress:null,topKeyUp:null,topLoad:null,topMouseDown:null,topMouseMove:null,topMouseOut:null,topMouseOver:null,topMouseUp:null,topPaste:null,topReset:null,topScroll:null,topSelectionChange:null,topSubmit:null,topTextInput:null,topTouchCancel:null,topTouchEnd:null,topTouchMove:null,topTouchStart:null,topWheel:null}),i={topLevelTypes:o,PropagationPhases:r};t.exports=i},{"./keyMirror":137}],17:[function(e,t){var n=e("./emptyFunction"),r={listen:function(e,t,n){return e.addEventListener?(e.addEventListener(t,n,!1),{remove:function(){e.removeEventListener(t,n,!1)}}):e.attachEvent?(e.attachEvent("on"+t,n),{remove:function(){e.detachEvent("on"+t,n)}}):void 0},capture:function(e,t,r){return e.addEventListener?(e.addEventListener(t,r,!0),{remove:function(){e.removeEventListener(t,r,!0)}}):{remove:n}},registerDefault:function(){}};t.exports=r},{"./emptyFunction":113}],18:[function(e,t){"use strict";var n=e("./EventPluginRegistry"),r=e("./EventPluginUtils"),o=e("./accumulate"),i=e("./forEachAccumulated"),a=e("./invariant"),s=(e("./isEventSupported"),e("./monitorCodeUse"),{}),u=null,c=function(e){if(e){var t=r.executeDispatch,o=n.getPluginModuleForEvent(e);o&&o.executeDispatch&&(t=o.executeDispatch),r.executeDispatchesInOrder(e,t),e.isPersistent()||e.constructor.release(e)}},l=null,p={injection:{injectMount:r.injection.injectMount,injectInstanceHandle:function(e){l=e},getInstanceHandle:function(){return l},injectEventPluginOrder:n.injectEventPluginOrder,injectEventPluginsByName:n.injectEventPluginsByName},eventNameDispatchConfigs:n.eventNameDispatchConfigs,registrationNameModules:n.registrationNameModules,putListener:function(e,t,n){a(!n||"function"==typeof n);var r=s[t]||(s[t]={});r[e]=n},getListener:function(e,t){var n=s[t];return n&&n[e]},deleteListener:function(e,t){var n=s[t];n&&delete n[e]},deleteAllListeners:function(e){for(var t in s)delete s[t][e]},extractEvents:function(e,t,r,i){for(var a,s=n.plugins,u=0,c=s.length;c>u;u++){var l=s[u];if(l){var p=l.extractEvents(e,t,r,i);p&&(a=o(a,p))}}return a},enqueueEvents:function(e){e&&(u=o(u,e))},processEventQueue:function(){var e=u;u=null,i(e,c),a(!u)},__purge:function(){s={}},__getListenerBank:function(){return s}};t.exports=p},{"./EventPluginRegistry":19,"./EventPluginUtils":20,"./accumulate":103,"./forEachAccumulated":118,"./invariant":131,"./isEventSupported":132,"./monitorCodeUse":145}],19:[function(e,t){"use strict";function n(){if(a)for(var e in s){var t=s[e],n=a.indexOf(e);if(i(n>-1),!u.plugins[n]){i(t.extractEvents),u.plugins[n]=t;var o=t.eventTypes;for(var c in o)i(r(o[c],t,c))}}}function r(e,t,n){i(!u.eventNameDispatchConfigs.hasOwnProperty(n)),u.eventNameDispatchConfigs[n]=e;var r=e.phasedRegistrationNames;if(r){for(var a in r)if(r.hasOwnProperty(a)){var s=r[a];o(s,t,n)}return!0}return e.registrationName?(o(e.registrationName,t,n),!0):!1}function o(e,t,n){i(!u.registrationNameModules[e]),u.registrationNameModules[e]=t,u.registrationNameDependencies[e]=t.eventTypes[n].dependencies}var i=e("./invariant"),a=null,s={},u={plugins:[],eventNameDispatchConfigs:{},registrationNameModules:{},registrationNameDependencies:{},injectEventPluginOrder:function(e){i(!a),a=Array.prototype.slice.call(e),n()},injectEventPluginsByName:function(e){var t=!1;for(var r in e)if(e.hasOwnProperty(r)){var o=e[r];s.hasOwnProperty(r)&&s[r]===o||(i(!s[r]),s[r]=o,t=!0)}t&&n()},getPluginModuleForEvent:function(e){var t=e.dispatchConfig;if(t.registrationName)return u.registrationNameModules[t.registrationName]||null;for(var n in t.phasedRegistrationNames)if(t.phasedRegistrationNames.hasOwnProperty(n)){var r=u.registrationNameModules[t.phasedRegistrationNames[n]];if(r)return r}return null},_resetEventPlugins:function(){a=null;for(var e in s)s.hasOwnProperty(e)&&delete s[e];u.plugins.length=0;var t=u.eventNameDispatchConfigs;for(var n in t)t.hasOwnProperty(n)&&delete t[n];var r=u.registrationNameModules;for(var o in r)r.hasOwnProperty(o)&&delete r[o]}};t.exports=u},{"./invariant":131}],20:[function(e,t){"use strict";function n(e){return e===v.topMouseUp||e===v.topTouchEnd||e===v.topTouchCancel}function r(e){return e===v.topMouseMove||e===v.topTouchMove}function o(e){return e===v.topMouseDown||e===v.topTouchStart}function i(e,t){var n=e._dispatchListeners,r=e._dispatchIDs;if(Array.isArray(n))for(var o=0;o<n.length&&!e.isPropagationStopped();o++)t(e,n[o],r[o]);else n&&t(e,n,r)}function a(e,t,n){e.currentTarget=h.Mount.getNode(n);var r=t(e,n);return e.currentTarget=null,r}function s(e,t){i(e,t),e._dispatchListeners=null,e._dispatchIDs=null}function u(e){var t=e._dispatchListeners,n=e._dispatchIDs;if(Array.isArray(t)){for(var r=0;r<t.length&&!e.isPropagationStopped();r++)if(t[r](e,n[r]))return n[r]}else if(t&&t(e,n))return n;return null}function c(e){var t=u(e);return e._dispatchIDs=null,e._dispatchListeners=null,t}function l(e){var t=e._dispatchListeners,n=e._dispatchIDs;f(!Array.isArray(t));var r=t?t(e,n):null;return e._dispatchListeners=null,e._dispatchIDs=null,r}function p(e){return!!e._dispatchListeners}var d=e("./EventConstants"),f=e("./invariant"),h={Mount:null,injectMount:function(e){h.Mount=e}},v=d.topLevelTypes,m={isEndish:n,isMoveish:r,isStartish:o,executeDirectDispatch:l,executeDispatch:a,executeDispatchesInOrder:s,executeDispatchesInOrderStopAtTrue:c,hasDispatches:p,injection:h,useTouchEvents:!1};t.exports=m},{"./EventConstants":16,"./invariant":131}],21:[function(e,t){"use strict";function n(e,t,n){var r=t.dispatchConfig.phasedRegistrationNames[n];return v(e,r)}function r(e,t,r){var o=t?h.bubbled:h.captured,i=n(e,r,o);i&&(r._dispatchListeners=d(r._dispatchListeners,i),r._dispatchIDs=d(r._dispatchIDs,e))}function o(e){e&&e.dispatchConfig.phasedRegistrationNames&&p.injection.getInstanceHandle().traverseTwoPhase(e.dispatchMarker,r,e)}function i(e,t,n){if(n&&n.dispatchConfig.registrationName){var r=n.dispatchConfig.registrationName,o=v(e,r);o&&(n._dispatchListeners=d(n._dispatchListeners,o),n._dispatchIDs=d(n._dispatchIDs,e))}}function a(e){e&&e.dispatchConfig.registrationName&&i(e.dispatchMarker,null,e)}function s(e){f(e,o)}function u(e,t,n,r){p.injection.getInstanceHandle().traverseEnterLeave(n,r,i,e,t)}function c(e){f(e,a)}var l=e("./EventConstants"),p=e("./EventPluginHub"),d=e("./accumulate"),f=e("./forEachAccumulated"),h=l.PropagationPhases,v=p.getListener,m={accumulateTwoPhaseDispatches:s,accumulateDirectDispatches:c,accumulateEnterLeaveDispatches:u};t.exports=m},{"./EventConstants":16,"./EventPluginHub":18,"./accumulate":103,"./forEachAccumulated":118}],22:[function(e,t){"use strict";var n=!("undefined"==typeof window||!window.document||!window.document.createElement),r={canUseDOM:n,canUseWorkers:"undefined"!=typeof Worker,canUseEventListeners:n&&!(!window.addEventListener&&!window.attachEvent),canUseViewport:n&&!!window.screen,isInWorker:!n};t.exports=r},{}],23:[function(e,t){"use strict";var n,r=e("./DOMProperty"),o=e("./ExecutionEnvironment"),i=r.injection.MUST_USE_ATTRIBUTE,a=r.injection.MUST_USE_PROPERTY,s=r.injection.HAS_BOOLEAN_VALUE,u=r.injection.HAS_SIDE_EFFECTS,c=r.injection.HAS_NUMERIC_VALUE,l=r.injection.HAS_POSITIVE_NUMERIC_VALUE,p=r.injection.HAS_OVERLOADED_BOOLEAN_VALUE;if(o.canUseDOM){var d=document.implementation;n=d&&d.hasFeature&&d.hasFeature("http://www.w3.org/TR/SVG11/feature#BasicStructure","1.1")}var f={isCustomAttribute:RegExp.prototype.test.bind(/^(data|aria)-[a-z_][a-z\d_.\-]*$/),Properties:{accept:null,accessKey:null,action:null,allowFullScreen:i|s,allowTransparency:i,alt:null,async:s,autoComplete:null,autoPlay:s,cellPadding:null,cellSpacing:null,charSet:i,checked:a|s,className:n?i:a,cols:i|l,colSpan:null,content:null,contentEditable:null,contextMenu:i,controls:a|s,coords:null,crossOrigin:null,data:null,dateTime:i,defer:s,dir:null,disabled:i|s,download:p,draggable:null,encType:null,form:i,formNoValidate:s,frameBorder:i,height:i,hidden:i|s,href:null,hrefLang:null,htmlFor:null,httpEquiv:null,icon:null,id:a,label:null,lang:null,list:null,loop:a|s,max:null,maxLength:i,mediaGroup:null,method:null,min:null,multiple:a|s,muted:a|s,name:null,noValidate:s,pattern:null,placeholder:null,poster:null,preload:null,radioGroup:null,readOnly:a|s,rel:null,required:s,role:i,rows:i|l,rowSpan:null,sandbox:null,scope:null,scrollLeft:a,scrolling:null,scrollTop:a,seamless:i|s,selected:a|s,shape:null,size:i|l,span:l,spellCheck:null,src:null,srcDoc:a,srcSet:null,start:c,step:null,style:null,tabIndex:null,target:null,title:null,type:null,useMap:null,value:a|u,width:i,wmode:i,autoCapitalize:null,autoCorrect:null,itemProp:i,itemScope:i|s,itemType:i,property:null},DOMAttributeNames:{className:"class",htmlFor:"for",httpEquiv:"http-equiv"},DOMPropertyNames:{autoCapitalize:"autocapitalize",autoComplete:"autocomplete",autoCorrect:"autocorrect",autoFocus:"autofocus",autoPlay:"autoplay",encType:"enctype",hrefLang:"hreflang",radioGroup:"radiogroup",spellCheck:"spellcheck",srcDoc:"srcdoc",srcSet:"srcset"}};t.exports=f},{"./DOMProperty":11,"./ExecutionEnvironment":22}],24:[function(e,t){"use strict";var n=e("./ReactLink"),r=e("./ReactStateSetters"),o={linkState:function(e){return new n(this.state[e],r.createStateKeySetter(this,e))}};t.exports=o},{"./ReactLink":63,"./ReactStateSetters":79}],25:[function(e,t){"use strict";function n(e){u(null==e.props.checkedLink||null==e.props.valueLink)}function r(e){n(e),u(null==e.props.value&&null==e.props.onChange)}function o(e){n(e),u(null==e.props.checked&&null==e.props.onChange)}function i(e){this.props.valueLink.requestChange(e.target.value)}function a(e){this.props.checkedLink.requestChange(e.target.checked)}var s=e("./ReactPropTypes"),u=e("./invariant"),c={button:!0,checkbox:!0,image:!0,hidden:!0,radio:!0,reset:!0,submit:!0},l={Mixin:{propTypes:{value:function(e,t){return!e[t]||c[e.type]||e.onChange||e.readOnly||e.disabled?void 0:new Error("You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.")},checked:function(e,t){return!e[t]||e.onChange||e.readOnly||e.disabled?void 0:new Error("You provided a `checked` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultChecked`. Otherwise, set either `onChange` or `readOnly`.")},onChange:s.func}},getValue:function(e){return e.props.valueLink?(r(e),e.props.valueLink.value):e.props.value},getChecked:function(e){return e.props.checkedLink?(o(e),e.props.checkedLink.value):e.props.checked},getOnChange:function(e){return e.props.valueLink?(r(e),i):e.props.checkedLink?(o(e),a):e.props.onChange}};t.exports=l},{"./ReactPropTypes":73,"./invariant":131}],26:[function(e,t){"use strict";function n(e){e.remove()}var r=e("./ReactBrowserEventEmitter"),o=e("./accumulate"),i=e("./forEachAccumulated"),a=e("./invariant"),s={trapBubbledEvent:function(e,t){a(this.isMounted());var n=r.trapBubbledEvent(e,t,this.getDOMNode());this._localEventListeners=o(this._localEventListeners,n)},componentWillUnmount:function(){this._localEventListeners&&i(this._localEventListeners,n)}};t.exports=s},{"./ReactBrowserEventEmitter":31,"./accumulate":103,"./forEachAccumulated":118,"./invariant":131}],27:[function(e,t){"use strict";var n=e("./EventConstants"),r=e("./emptyFunction"),o=n.topLevelTypes,i={eventTypes:null,extractEvents:function(e,t,n,i){if(e===o.topTouchStart){var a=i.target;a&&!a.onclick&&(a.onclick=r)}}};t.exports=i},{"./EventConstants":16,"./emptyFunction":113}],28:[function(e,t){"use strict";var n=e("./invariant"),r=function(e){var t=this;if(t.instancePool.length){var n=t.instancePool.pop();return t.call(n,e),n}return new t(e)},o=function(e,t){var n=this;if(n.instancePool.length){var r=n.instancePool.pop();return n.call(r,e,t),r}return new n(e,t)},i=function(e,t,n){var r=this;if(r.instancePool.length){var o=r.instancePool.pop();return r.call(o,e,t,n),o}return new r(e,t,n)},a=function(e,t,n,r,o){var i=this;if(i.instancePool.length){var a=i.instancePool.pop();return i.call(a,e,t,n,r,o),a}return new i(e,t,n,r,o)},s=function(e){var t=this;n(e instanceof t),e.destructor&&e.destructor(),t.instancePool.length<t.poolSize&&t.instancePool.push(e)},u=10,c=r,l=function(e,t){var n=e;return n.instancePool=[],n.getPooled=t||c,n.poolSize||(n.poolSize=u),n.release=s,n},p={addPoolingTo:l,oneArgumentPooler:r,twoArgumentPooler:o,threeArgumentPooler:i,fiveArgumentPooler:a};t.exports=p},{"./invariant":131}],29:[function(e,t){"use strict";var n=e("./DOMPropertyOperations"),r=e("./EventPluginUtils"),o=e("./ReactChildren"),i=e("./ReactComponent"),a=e("./ReactCompositeComponent"),s=e("./ReactContext"),u=e("./ReactCurrentOwner"),c=e("./ReactDescriptor"),l=e("./ReactDOM"),p=e("./ReactDOMComponent"),d=e("./ReactDefaultInjection"),f=e("./ReactInstanceHandles"),h=e("./ReactMount"),v=e("./ReactMultiChild"),m=e("./ReactPerf"),g=e("./ReactPropTypes"),y=e("./ReactServerRendering"),C=e("./ReactTextComponent"),E=e("./onlyChild");d.inject();var R={Children:{map:o.map,forEach:o.forEach,count:o.count,only:E},DOM:l,PropTypes:g,initializeTouchEvents:function(e){r.useTouchEvents=e},createClass:a.createClass,createDescriptor:function(e){var t=Array.prototype.slice.call(arguments,1);return e.apply(null,t)},constructAndRenderComponent:h.constructAndRenderComponent,constructAndRenderComponentByID:h.constructAndRenderComponentByID,renderComponent:m.measure("React","renderComponent",h.renderComponent),renderComponentToString:y.renderComponentToString,renderComponentToStaticMarkup:y.renderComponentToStaticMarkup,unmountComponentAtNode:h.unmountComponentAtNode,isValidClass:c.isValidFactory,isValidComponent:c.isValidDescriptor,withContext:s.withContext,__internals:{Component:i,CurrentOwner:u,DOMComponent:p,DOMPropertyOperations:n,InstanceHandles:f,Mount:h,MultiChild:v,TextComponent:C}};R.version="0.11.1",t.exports=R},{"./DOMPropertyOperations":12,"./EventPluginUtils":20,"./ReactChildren":34,"./ReactComponent":35,"./ReactCompositeComponent":38,"./ReactContext":39,"./ReactCurrentOwner":40,"./ReactDOM":41,"./ReactDOMComponent":43,"./ReactDefaultInjection":53,"./ReactDescriptor":54,"./ReactInstanceHandles":62,"./ReactMount":65,"./ReactMultiChild":66,"./ReactPerf":69,"./ReactPropTypes":73,"./ReactServerRendering":77,"./ReactTextComponent":80,"./onlyChild":146}],30:[function(e,t){"use strict";
+var n=e("./ReactEmptyComponent"),r=e("./ReactMount"),o=e("./invariant"),i={getDOMNode:function(){return o(this.isMounted()),n.isNullComponentID(this._rootNodeID)?null:r.getNode(this._rootNodeID)}};t.exports=i},{"./ReactEmptyComponent":56,"./ReactMount":65,"./invariant":131}],31:[function(e,t){"use strict";function n(e){return Object.prototype.hasOwnProperty.call(e,h)||(e[h]=d++,l[e[h]]={}),l[e[h]]}var r=e("./EventConstants"),o=e("./EventPluginHub"),i=e("./EventPluginRegistry"),a=e("./ReactEventEmitterMixin"),s=e("./ViewportMetrics"),u=e("./isEventSupported"),c=e("./merge"),l={},p=!1,d=0,f={topBlur:"blur",topChange:"change",topClick:"click",topCompositionEnd:"compositionend",topCompositionStart:"compositionstart",topCompositionUpdate:"compositionupdate",topContextMenu:"contextmenu",topCopy:"copy",topCut:"cut",topDoubleClick:"dblclick",topDrag:"drag",topDragEnd:"dragend",topDragEnter:"dragenter",topDragExit:"dragexit",topDragLeave:"dragleave",topDragOver:"dragover",topDragStart:"dragstart",topDrop:"drop",topFocus:"focus",topInput:"input",topKeyDown:"keydown",topKeyPress:"keypress",topKeyUp:"keyup",topMouseDown:"mousedown",topMouseMove:"mousemove",topMouseOut:"mouseout",topMouseOver:"mouseover",topMouseUp:"mouseup",topPaste:"paste",topScroll:"scroll",topSelectionChange:"selectionchange",topTextInput:"textInput",topTouchCancel:"touchcancel",topTouchEnd:"touchend",topTouchMove:"touchmove",topTouchStart:"touchstart",topWheel:"wheel"},h="_reactListenersID"+String(Math.random()).slice(2),v=c(a,{ReactEventListener:null,injection:{injectReactEventListener:function(e){e.setHandleTopLevel(v.handleTopLevel),v.ReactEventListener=e}},setEnabled:function(e){v.ReactEventListener&&v.ReactEventListener.setEnabled(e)},isEnabled:function(){return!(!v.ReactEventListener||!v.ReactEventListener.isEnabled())},listenTo:function(e,t){for(var o=t,a=n(o),s=i.registrationNameDependencies[e],c=r.topLevelTypes,l=0,p=s.length;p>l;l++){var d=s[l];a.hasOwnProperty(d)&&a[d]||(d===c.topWheel?u("wheel")?v.ReactEventListener.trapBubbledEvent(c.topWheel,"wheel",o):u("mousewheel")?v.ReactEventListener.trapBubbledEvent(c.topWheel,"mousewheel",o):v.ReactEventListener.trapBubbledEvent(c.topWheel,"DOMMouseScroll",o):d===c.topScroll?u("scroll",!0)?v.ReactEventListener.trapCapturedEvent(c.topScroll,"scroll",o):v.ReactEventListener.trapBubbledEvent(c.topScroll,"scroll",v.ReactEventListener.WINDOW_HANDLE):d===c.topFocus||d===c.topBlur?(u("focus",!0)?(v.ReactEventListener.trapCapturedEvent(c.topFocus,"focus",o),v.ReactEventListener.trapCapturedEvent(c.topBlur,"blur",o)):u("focusin")&&(v.ReactEventListener.trapBubbledEvent(c.topFocus,"focusin",o),v.ReactEventListener.trapBubbledEvent(c.topBlur,"focusout",o)),a[c.topBlur]=!0,a[c.topFocus]=!0):f.hasOwnProperty(d)&&v.ReactEventListener.trapBubbledEvent(d,f[d],o),a[d]=!0)}},trapBubbledEvent:function(e,t,n){return v.ReactEventListener.trapBubbledEvent(e,t,n)},trapCapturedEvent:function(e,t,n){return v.ReactEventListener.trapCapturedEvent(e,t,n)},ensureScrollValueMonitoring:function(){if(!p){var e=s.refreshScrollValues;v.ReactEventListener.monitorScrollValue(e),p=!0}},eventNameDispatchConfigs:o.eventNameDispatchConfigs,registrationNameModules:o.registrationNameModules,putListener:o.putListener,getListener:o.getListener,deleteListener:o.deleteListener,deleteAllListeners:o.deleteAllListeners});t.exports=v},{"./EventConstants":16,"./EventPluginHub":18,"./EventPluginRegistry":19,"./ReactEventEmitterMixin":58,"./ViewportMetrics":102,"./isEventSupported":132,"./merge":141}],32:[function(e,t){"use strict";var n=e("./React"),r=e("./ReactTransitionGroup"),o=e("./ReactCSSTransitionGroupChild"),i=n.createClass({displayName:"ReactCSSTransitionGroup",propTypes:{transitionName:n.PropTypes.string.isRequired,transitionEnter:n.PropTypes.bool,transitionLeave:n.PropTypes.bool},getDefaultProps:function(){return{transitionEnter:!0,transitionLeave:!0}},_wrapChild:function(e){return o({name:this.props.transitionName,enter:this.props.transitionEnter,leave:this.props.transitionLeave},e)},render:function(){return this.transferPropsTo(r({childFactory:this._wrapChild},this.props.children))}});t.exports=i},{"./React":29,"./ReactCSSTransitionGroupChild":33,"./ReactTransitionGroup":83}],33:[function(e,t){"use strict";var n=e("./React"),r=e("./CSSCore"),o=e("./ReactTransitionEvents"),i=e("./onlyChild"),a=17,s=n.createClass({displayName:"ReactCSSTransitionGroupChild",transition:function(e,t){var n=this.getDOMNode(),i=this.props.name+"-"+e,a=i+"-active",s=function(){r.removeClass(n,i),r.removeClass(n,a),o.removeEndEventListener(n,s),t&&t()};o.addEndEventListener(n,s),r.addClass(n,i),this.queueClass(a)},queueClass:function(e){this.classNameQueue.push(e),this.timeout||(this.timeout=setTimeout(this.flushClassNameQueue,a))},flushClassNameQueue:function(){this.isMounted()&&this.classNameQueue.forEach(r.addClass.bind(r,this.getDOMNode())),this.classNameQueue.length=0,this.timeout=null},componentWillMount:function(){this.classNameQueue=[]},componentWillUnmount:function(){this.timeout&&clearTimeout(this.timeout)},componentWillEnter:function(e){this.props.enter?this.transition("enter",e):e()},componentWillLeave:function(e){this.props.leave?this.transition("leave",e):e()},render:function(){return i(this.props.children)}});t.exports=s},{"./CSSCore":3,"./React":29,"./ReactTransitionEvents":82,"./onlyChild":146}],34:[function(e,t){"use strict";function n(e,t){this.forEachFunction=e,this.forEachContext=t}function r(e,t,n,r){var o=e;o.forEachFunction.call(o.forEachContext,t,r)}function o(e,t,o){if(null==e)return e;var i=n.getPooled(t,o);p(e,r,i),n.release(i)}function i(e,t,n){this.mapResult=e,this.mapFunction=t,this.mapContext=n}function a(e,t,n,r){var o=e,i=o.mapResult,a=!i.hasOwnProperty(n);if(a){var s=o.mapFunction.call(o.mapContext,t,r);i[n]=s}}function s(e,t,n){if(null==e)return e;var r={},o=i.getPooled(r,t,n);return p(e,a,o),i.release(o),r}function u(){return null}function c(e){return p(e,u,null)}var l=e("./PooledClass"),p=e("./traverseAllChildren"),d=(e("./warning"),l.twoArgumentPooler),f=l.threeArgumentPooler;l.addPoolingTo(n,d),l.addPoolingTo(i,f);var h={forEach:o,map:s,count:c};t.exports=h},{"./PooledClass":28,"./traverseAllChildren":151,"./warning":153}],35:[function(e,t){"use strict";var n=e("./ReactDescriptor"),r=e("./ReactOwner"),o=e("./ReactUpdates"),i=e("./invariant"),a=e("./keyMirror"),s=e("./merge"),u=a({MOUNTED:null,UNMOUNTED:null}),c=!1,l=null,p=null,d={injection:{injectEnvironment:function(e){i(!c),p=e.mountImageIntoNode,l=e.unmountIDFromEnvironment,d.BackendIDOperations=e.BackendIDOperations,c=!0}},LifeCycle:u,BackendIDOperations:null,Mixin:{isMounted:function(){return this._lifeCycleState===u.MOUNTED},setProps:function(e,t){var n=this._pendingDescriptor||this._descriptor;this.replaceProps(s(n.props,e),t)},replaceProps:function(e,t){i(this.isMounted()),i(0===this._mountDepth),this._pendingDescriptor=n.cloneAndReplaceProps(this._pendingDescriptor||this._descriptor,e),o.enqueueUpdate(this,t)},_setPropsInternal:function(e,t){var r=this._pendingDescriptor||this._descriptor;this._pendingDescriptor=n.cloneAndReplaceProps(r,s(r.props,e)),o.enqueueUpdate(this,t)},construct:function(e){this.props=e.props,this._owner=e._owner,this._lifeCycleState=u.UNMOUNTED,this._pendingCallbacks=null,this._descriptor=e,this._pendingDescriptor=null},mountComponent:function(e,t,n){i(!this.isMounted());var o=this._descriptor.props;if(null!=o.ref){var a=this._descriptor._owner;r.addComponentAsRefTo(this,o.ref,a)}this._rootNodeID=e,this._lifeCycleState=u.MOUNTED,this._mountDepth=n},unmountComponent:function(){i(this.isMounted());var e=this.props;null!=e.ref&&r.removeComponentAsRefFrom(this,e.ref,this._owner),l(this._rootNodeID),this._rootNodeID=null,this._lifeCycleState=u.UNMOUNTED},receiveComponent:function(e,t){i(this.isMounted()),this._pendingDescriptor=e,this.performUpdateIfNecessary(t)},performUpdateIfNecessary:function(e){if(null!=this._pendingDescriptor){var t=this._descriptor,n=this._pendingDescriptor;this._descriptor=n,this.props=n.props,this._owner=n._owner,this._pendingDescriptor=null,this.updateComponent(e,t)}},updateComponent:function(e,t){var n=this._descriptor;(n._owner!==t._owner||n.props.ref!==t.props.ref)&&(null!=t.props.ref&&r.removeComponentAsRefFrom(this,t.props.ref,t._owner),null!=n.props.ref&&r.addComponentAsRefTo(this,n.props.ref,n._owner))},mountComponentIntoNode:function(e,t,n){var r=o.ReactReconcileTransaction.getPooled();r.perform(this._mountComponentIntoNode,this,e,t,r,n),o.ReactReconcileTransaction.release(r)},_mountComponentIntoNode:function(e,t,n,r){var o=this.mountComponent(e,n,0);p(o,t,r)},isOwnedBy:function(e){return this._owner===e},getSiblingByRef:function(e){var t=this._owner;return t&&t.refs?t.refs[e]:null}}};t.exports=d},{"./ReactDescriptor":54,"./ReactOwner":68,"./ReactUpdates":84,"./invariant":131,"./keyMirror":137,"./merge":141}],36:[function(e,t){"use strict";var n=e("./ReactDOMIDOperations"),r=e("./ReactMarkupChecksum"),o=e("./ReactMount"),i=e("./ReactPerf"),a=e("./ReactReconcileTransaction"),s=e("./getReactRootElementInContainer"),u=e("./invariant"),c=e("./setInnerHTML"),l=1,p=9,d={ReactReconcileTransaction:a,BackendIDOperations:n,unmountIDFromEnvironment:function(e){o.purgeID(e)},mountImageIntoNode:i.measure("ReactComponentBrowserEnvironment","mountImageIntoNode",function(e,t,n){if(u(t&&(t.nodeType===l||t.nodeType===p)),n){if(r.canReuseMarkup(e,s(t)))return;u(t.nodeType!==p)}u(t.nodeType!==p),c(t,e)})};t.exports=d},{"./ReactDOMIDOperations":45,"./ReactMarkupChecksum":64,"./ReactMount":65,"./ReactPerf":69,"./ReactReconcileTransaction":75,"./getReactRootElementInContainer":125,"./invariant":131,"./setInnerHTML":147}],37:[function(e,t){"use strict";var n=e("./shallowEqual"),r={shouldComponentUpdate:function(e,t){return!n(this.props,e)||!n(this.state,t)}};t.exports=r},{"./shallowEqual":148}],38:[function(e,t){"use strict";function n(e){var t=e._owner||null;return t&&t.constructor&&t.constructor.displayName?" Check the render method of `"+t.constructor.displayName+"`.":""}function r(e,t){for(var n in t)t.hasOwnProperty(n)&&D("function"==typeof t[n])}function o(e,t){var n=_.hasOwnProperty(t)?_[t]:null;k.hasOwnProperty(t)&&D(n===S.OVERRIDE_BASE),e.hasOwnProperty(t)&&D(n===S.DEFINE_MANY||n===S.DEFINE_MANY_MERGED)}function i(e){var t=e._compositeLifeCycleState;D(e.isMounted()||t===N.MOUNTING),D(t!==N.RECEIVING_STATE),D(t!==N.UNMOUNTING)}function a(e,t){D(!h.isValidFactory(t)),D(!h.isValidDescriptor(t));var n=e.prototype;for(var r in t){var i=t[r];if(t.hasOwnProperty(r))if(o(n,r),I.hasOwnProperty(r))I[r](e,i);else{var a=_.hasOwnProperty(r),s=n.hasOwnProperty(r),u=i&&i.__reactDontBind,p="function"==typeof i,d=p&&!a&&!s&&!u;if(d)n.__reactAutoBindMap||(n.__reactAutoBindMap={}),n.__reactAutoBindMap[r]=i,n[r]=i;else if(s){var f=_[r];D(a&&(f===S.DEFINE_MANY_MERGED||f===S.DEFINE_MANY)),f===S.DEFINE_MANY_MERGED?n[r]=c(n[r],i):f===S.DEFINE_MANY&&(n[r]=l(n[r],i))}else n[r]=i}}}function s(e,t){if(t)for(var n in t){var r=t[n];if(t.hasOwnProperty(n)){var o=n in e,i=r;if(o){var a=e[n],s=typeof a,u=typeof r;D("function"===s&&"function"===u),i=l(a,r)}e[n]=i}}}function u(e,t){return D(e&&t&&"object"==typeof e&&"object"==typeof t),P(t,function(t,n){D(void 0===e[n]),e[n]=t}),e}function c(e,t){return function(){var n=e.apply(this,arguments),r=t.apply(this,arguments);return null==n?r:null==r?n:u(n,r)}}function l(e,t){return function(){e.apply(this,arguments),t.apply(this,arguments)}}var p=e("./ReactComponent"),d=e("./ReactContext"),f=e("./ReactCurrentOwner"),h=e("./ReactDescriptor"),v=(e("./ReactDescriptorValidator"),e("./ReactEmptyComponent")),m=e("./ReactErrorUtils"),g=e("./ReactOwner"),y=e("./ReactPerf"),C=e("./ReactPropTransferer"),E=e("./ReactPropTypeLocations"),R=(e("./ReactPropTypeLocationNames"),e("./ReactUpdates")),M=e("./instantiateReactComponent"),D=e("./invariant"),x=e("./keyMirror"),b=e("./merge"),O=e("./mixInto"),P=(e("./monitorCodeUse"),e("./mapObject")),T=e("./shouldUpdateReactComponent"),S=(e("./warning"),x({DEFINE_ONCE:null,DEFINE_MANY:null,OVERRIDE_BASE:null,DEFINE_MANY_MERGED:null})),w=[],_={mixins:S.DEFINE_MANY,statics:S.DEFINE_MANY,propTypes:S.DEFINE_MANY,contextTypes:S.DEFINE_MANY,childContextTypes:S.DEFINE_MANY,getDefaultProps:S.DEFINE_MANY_MERGED,getInitialState:S.DEFINE_MANY_MERGED,getChildContext:S.DEFINE_MANY_MERGED,render:S.DEFINE_ONCE,componentWillMount:S.DEFINE_MANY,componentDidMount:S.DEFINE_MANY,componentWillReceiveProps:S.DEFINE_MANY,shouldComponentUpdate:S.DEFINE_ONCE,componentWillUpdate:S.DEFINE_MANY,componentDidUpdate:S.DEFINE_MANY,componentWillUnmount:S.DEFINE_MANY,updateComponent:S.OVERRIDE_BASE},I={displayName:function(e,t){e.displayName=t},mixins:function(e,t){if(t)for(var n=0;n<t.length;n++)a(e,t[n])},childContextTypes:function(e,t){r(e,t,E.childContext),e.childContextTypes=b(e.childContextTypes,t)},contextTypes:function(e,t){r(e,t,E.context),e.contextTypes=b(e.contextTypes,t)},getDefaultProps:function(e,t){e.getDefaultProps=e.getDefaultProps?c(e.getDefaultProps,t):t},propTypes:function(e,t){r(e,t,E.prop),e.propTypes=b(e.propTypes,t)},statics:function(e,t){s(e,t)}},N=x({MOUNTING:null,UNMOUNTING:null,RECEIVING_PROPS:null,RECEIVING_STATE:null}),k={construct:function(){p.Mixin.construct.apply(this,arguments),g.Mixin.construct.apply(this,arguments),this.state=null,this._pendingState=null,this.context=null,this._compositeLifeCycleState=null},isMounted:function(){return p.Mixin.isMounted.call(this)&&this._compositeLifeCycleState!==N.MOUNTING},mountComponent:y.measure("ReactCompositeComponent","mountComponent",function(e,t,n){p.Mixin.mountComponent.call(this,e,t,n),this._compositeLifeCycleState=N.MOUNTING,this.__reactAutoBindMap&&this._bindAutoBindMethods(),this.context=this._processContext(this._descriptor._context),this.props=this._processProps(this.props),this.state=this.getInitialState?this.getInitialState():null,D("object"==typeof this.state&&!Array.isArray(this.state)),this._pendingState=null,this._pendingForceUpdate=!1,this.componentWillMount&&(this.componentWillMount(),this._pendingState&&(this.state=this._pendingState,this._pendingState=null)),this._renderedComponent=M(this._renderValidatedComponent()),this._compositeLifeCycleState=null;var r=this._renderedComponent.mountComponent(e,t,n+1);return this.componentDidMount&&t.getReactMountReady().enqueue(this.componentDidMount,this),r}),unmountComponent:function(){this._compositeLifeCycleState=N.UNMOUNTING,this.componentWillUnmount&&this.componentWillUnmount(),this._compositeLifeCycleState=null,this._renderedComponent.unmountComponent(),this._renderedComponent=null,p.Mixin.unmountComponent.call(this)},setState:function(e,t){D("object"==typeof e||null==e),this.replaceState(b(this._pendingState||this.state,e),t)},replaceState:function(e,t){i(this),this._pendingState=e,this._compositeLifeCycleState!==N.MOUNTING&&R.enqueueUpdate(this,t)},_processContext:function(e){var t=null,n=this.constructor.contextTypes;if(n){t={};for(var r in n)t[r]=e[r]}return t},_processChildContext:function(e){var t=this.getChildContext&&this.getChildContext();if(this.constructor.displayName||"ReactCompositeComponent",t){D("object"==typeof this.constructor.childContextTypes);for(var n in t)D(n in this.constructor.childContextTypes);return b(e,t)}return e},_processProps:function(e){var t,n=this.constructor.defaultProps;if(n){t=b(e);for(var r in n)"undefined"==typeof t[r]&&(t[r]=n[r])}else t=e;return t},_checkPropTypes:function(e,t,r){var o=this.constructor.displayName;for(var i in e)if(e.hasOwnProperty(i)){var a=e[i](t,i,o,r);a instanceof Error&&n(this)}},performUpdateIfNecessary:function(e){var t=this._compositeLifeCycleState;if(t!==N.MOUNTING&&t!==N.RECEIVING_PROPS&&(null!=this._pendingDescriptor||null!=this._pendingState||this._pendingForceUpdate)){var n=this.context,r=this.props,o=this._descriptor;null!=this._pendingDescriptor&&(o=this._pendingDescriptor,n=this._processContext(o._context),r=this._processProps(o.props),this._pendingDescriptor=null,this._compositeLifeCycleState=N.RECEIVING_PROPS,this.componentWillReceiveProps&&this.componentWillReceiveProps(r,n)),this._compositeLifeCycleState=N.RECEIVING_STATE;var i=this._pendingState||this.state;this._pendingState=null;try{var a=this._pendingForceUpdate||!this.shouldComponentUpdate||this.shouldComponentUpdate(r,i,n);a?(this._pendingForceUpdate=!1,this._performComponentUpdate(o,r,i,n,e)):(this._descriptor=o,this.props=r,this.state=i,this.context=n,this._owner=o._owner)}finally{this._compositeLifeCycleState=null}}},_performComponentUpdate:function(e,t,n,r,o){var i=this._descriptor,a=this.props,s=this.state,u=this.context;this.componentWillUpdate&&this.componentWillUpdate(t,n,r),this._descriptor=e,this.props=t,this.state=n,this.context=r,this._owner=e._owner,this.updateComponent(o,i),this.componentDidUpdate&&o.getReactMountReady().enqueue(this.componentDidUpdate.bind(this,a,s,u),this)},receiveComponent:function(e,t){(e!==this._descriptor||null==e._owner)&&p.Mixin.receiveComponent.call(this,e,t)},updateComponent:y.measure("ReactCompositeComponent","updateComponent",function(e,t){p.Mixin.updateComponent.call(this,e,t);var n=this._renderedComponent,r=n._descriptor,o=this._renderValidatedComponent();if(T(r,o))n.receiveComponent(o,e);else{var i=this._rootNodeID,a=n._rootNodeID;n.unmountComponent(),this._renderedComponent=M(o);var s=this._renderedComponent.mountComponent(i,e,this._mountDepth+1);p.BackendIDOperations.dangerouslyReplaceNodeWithMarkupByID(a,s)}}),forceUpdate:function(e){var t=this._compositeLifeCycleState;D(this.isMounted()||t===N.MOUNTING),D(t!==N.RECEIVING_STATE&&t!==N.UNMOUNTING),this._pendingForceUpdate=!0,R.enqueueUpdate(this,e)},_renderValidatedComponent:y.measure("ReactCompositeComponent","_renderValidatedComponent",function(){var e,t=d.current;d.current=this._processChildContext(this._descriptor._context),f.current=this;try{e=this.render(),null===e||e===!1?(e=v.getEmptyComponent(),v.registerNullComponentID(this._rootNodeID)):v.deregisterNullComponentID(this._rootNodeID)}finally{d.current=t,f.current=null}return D(h.isValidDescriptor(e)),e}),_bindAutoBindMethods:function(){for(var e in this.__reactAutoBindMap)if(this.__reactAutoBindMap.hasOwnProperty(e)){var t=this.__reactAutoBindMap[e];this[e]=this._bindAutoBindMethod(m.guard(t,this.constructor.displayName+"."+e))}},_bindAutoBindMethod:function(e){var t=this,n=function(){return e.apply(t,arguments)};return n}},A=function(){};O(A,p.Mixin),O(A,g.Mixin),O(A,C.Mixin),O(A,k);var L={LifeCycle:N,Base:A,createClass:function(e){var t=function(e,t){this.construct(e,t)};t.prototype=new A,t.prototype.constructor=t,w.forEach(a.bind(null,t)),a(t,e),t.getDefaultProps&&(t.defaultProps=t.getDefaultProps()),D(t.prototype.render);for(var n in _)t.prototype[n]||(t.prototype[n]=null);var r=h.createFactory(t);return r},injection:{injectMixin:function(e){w.push(e)}}};t.exports=L},{"./ReactComponent":35,"./ReactContext":39,"./ReactCurrentOwner":40,"./ReactDescriptor":54,"./ReactDescriptorValidator":55,"./ReactEmptyComponent":56,"./ReactErrorUtils":57,"./ReactOwner":68,"./ReactPerf":69,"./ReactPropTransferer":70,"./ReactPropTypeLocationNames":71,"./ReactPropTypeLocations":72,"./ReactUpdates":84,"./instantiateReactComponent":130,"./invariant":131,"./keyMirror":137,"./mapObject":139,"./merge":141,"./mixInto":144,"./monitorCodeUse":145,"./shouldUpdateReactComponent":149,"./warning":153}],39:[function(e,t){"use strict";var n=e("./merge"),r={current:{},withContext:function(e,t){var o,i=r.current;r.current=n(i,e);try{o=t()}finally{r.current=i}return o}};t.exports=r},{"./merge":141}],40:[function(e,t){"use strict";var n={current:null};t.exports=n},{}],41:[function(e,t){"use strict";function n(e,t){var n=function(e){this.construct(e)};n.prototype=new o(t,e),n.prototype.constructor=n,n.displayName=t;var i=r.createFactory(n);return i}var r=e("./ReactDescriptor"),o=(e("./ReactDescriptorValidator"),e("./ReactDOMComponent")),i=e("./mergeInto"),a=e("./mapObject"),s=a({a:!1,abbr:!1,address:!1,area:!0,article:!1,aside:!1,audio:!1,b:!1,base:!0,bdi:!1,bdo:!1,big:!1,blockquote:!1,body:!1,br:!0,button:!1,canvas:!1,caption:!1,cite:!1,code:!1,col:!0,colgroup:!1,data:!1,datalist:!1,dd:!1,del:!1,details:!1,dfn:!1,div:!1,dl:!1,dt:!1,em:!1,embed:!0,fieldset:!1,figcaption:!1,figure:!1,footer:!1,form:!1,h1:!1,h2:!1,h3:!1,h4:!1,h5:!1,h6:!1,head:!1,header:!1,hr:!0,html:!1,i:!1,iframe:!1,img:!0,input:!0,ins:!1,kbd:!1,keygen:!0,label:!1,legend:!1,li:!1,link:!0,main:!1,map:!1,mark:!1,menu:!1,menuitem:!1,meta:!0,meter:!1,nav:!1,noscript:!1,object:!1,ol:!1,optgroup:!1,option:!1,output:!1,p:!1,param:!0,pre:!1,progress:!1,q:!1,rp:!1,rt:!1,ruby:!1,s:!1,samp:!1,script:!1,section:!1,select:!1,small:!1,source:!0,span:!1,strong:!1,style:!1,sub:!1,summary:!1,sup:!1,table:!1,tbody:!1,td:!1,textarea:!1,tfoot:!1,th:!1,thead:!1,time:!1,title:!1,tr:!1,track:!0,u:!1,ul:!1,"var":!1,video:!1,wbr:!0,circle:!1,defs:!1,ellipse:!1,g:!1,line:!1,linearGradient:!1,mask:!1,path:!1,pattern:!1,polygon:!1,polyline:!1,radialGradient:!1,rect:!1,stop:!1,svg:!1,text:!1,tspan:!1},n),u={injectComponentClasses:function(e){i(s,e)}};s.injection=u,t.exports=s},{"./ReactDOMComponent":43,"./ReactDescriptor":54,"./ReactDescriptorValidator":55,"./mapObject":139,"./mergeInto":143}],42:[function(e,t){"use strict";var n=e("./AutoFocusMixin"),r=e("./ReactBrowserComponentMixin"),o=e("./ReactCompositeComponent"),i=e("./ReactDOM"),a=e("./keyMirror"),s=i.button,u=a({onClick:!0,onDoubleClick:!0,onMouseDown:!0,onMouseMove:!0,onMouseUp:!0,onClickCapture:!0,onDoubleClickCapture:!0,onMouseDownCapture:!0,onMouseMoveCapture:!0,onMouseUpCapture:!0}),c=o.createClass({displayName:"ReactDOMButton",mixins:[n,r],render:function(){var e={};for(var t in this.props)!this.props.hasOwnProperty(t)||this.props.disabled&&u[t]||(e[t]=this.props[t]);return s(e,this.props.children)}});t.exports=c},{"./AutoFocusMixin":1,"./ReactBrowserComponentMixin":30,"./ReactCompositeComponent":38,"./ReactDOM":41,"./keyMirror":137}],43:[function(e,t){"use strict";function n(e){e&&(v(null==e.children||null==e.dangerouslySetInnerHTML),v(null==e.style||"object"==typeof e.style))}function r(e,t,n,r){var o=p.findReactContainerForID(e);if(o){var i=o.nodeType===x?o.ownerDocument:o;E(t,i)}r.getPutListenerQueue().enqueuePutListener(e,t,n)}function o(e,t){this._tagOpen="<"+e,this._tagClose=t?"":"</"+e+">",this.tagName=e.toUpperCase()}var i=e("./CSSPropertyOperations"),a=e("./DOMProperty"),s=e("./DOMPropertyOperations"),u=e("./ReactBrowserComponentMixin"),c=e("./ReactComponent"),l=e("./ReactBrowserEventEmitter"),p=e("./ReactMount"),d=e("./ReactMultiChild"),f=e("./ReactPerf"),h=e("./escapeTextForBrowser"),v=e("./invariant"),m=e("./keyOf"),g=e("./merge"),y=e("./mixInto"),C=l.deleteListener,E=l.listenTo,R=l.registrationNameModules,M={string:!0,number:!0},D=m({style:null}),x=1;o.Mixin={mountComponent:f.measure("ReactDOMComponent","mountComponent",function(e,t,r){return c.Mixin.mountComponent.call(this,e,t,r),n(this.props),this._createOpenTagMarkupAndPutListeners(t)+this._createContentMarkup(t)+this._tagClose}),_createOpenTagMarkupAndPutListeners:function(e){var t=this.props,n=this._tagOpen;for(var o in t)if(t.hasOwnProperty(o)){var a=t[o];if(null!=a)if(R.hasOwnProperty(o))r(this._rootNodeID,o,a,e);else{o===D&&(a&&(a=t.style=g(t.style)),a=i.createMarkupForStyles(a));var u=s.createMarkupForProperty(o,a);u&&(n+=" "+u)}}if(e.renderToStaticMarkup)return n+">";var c=s.createMarkupForID(this._rootNodeID);return n+" "+c+">"},_createContentMarkup:function(e){var t=this.props.dangerouslySetInnerHTML;if(null!=t){if(null!=t.__html)return t.__html}else{var n=M[typeof this.props.children]?this.props.children:null,r=null!=n?null:this.props.children;if(null!=n)return h(n);if(null!=r){var o=this.mountChildren(r,e);return o.join("")}}return""},receiveComponent:function(e,t){(e!==this._descriptor||null==e._owner)&&c.Mixin.receiveComponent.call(this,e,t)},updateComponent:f.measure("ReactDOMComponent","updateComponent",function(e,t){n(this._descriptor.props),c.Mixin.updateComponent.call(this,e,t),this._updateDOMProperties(t.props,e),this._updateDOMChildren(t.props,e)}),_updateDOMProperties:function(e,t){var n,o,i,s=this.props;for(n in e)if(!s.hasOwnProperty(n)&&e.hasOwnProperty(n))if(n===D){var u=e[n];for(o in u)u.hasOwnProperty(o)&&(i=i||{},i[o]="")}else R.hasOwnProperty(n)?C(this._rootNodeID,n):(a.isStandardName[n]||a.isCustomAttribute(n))&&c.BackendIDOperations.deletePropertyByID(this._rootNodeID,n);for(n in s){var l=s[n],p=e[n];if(s.hasOwnProperty(n)&&l!==p)if(n===D)if(l&&(l=s.style=g(l)),p){for(o in p)!p.hasOwnProperty(o)||l&&l.hasOwnProperty(o)||(i=i||{},i[o]="");for(o in l)l.hasOwnProperty(o)&&p[o]!==l[o]&&(i=i||{},i[o]=l[o])}else i=l;else R.hasOwnProperty(n)?r(this._rootNodeID,n,l,t):(a.isStandardName[n]||a.isCustomAttribute(n))&&c.BackendIDOperations.updatePropertyByID(this._rootNodeID,n,l)}i&&c.BackendIDOperations.updateStylesByID(this._rootNodeID,i)},_updateDOMChildren:function(e,t){var n=this.props,r=M[typeof e.children]?e.children:null,o=M[typeof n.children]?n.children:null,i=e.dangerouslySetInnerHTML&&e.dangerouslySetInnerHTML.__html,a=n.dangerouslySetInnerHTML&&n.dangerouslySetInnerHTML.__html,s=null!=r?null:e.children,u=null!=o?null:n.children,l=null!=r||null!=i,p=null!=o||null!=a;null!=s&&null==u?this.updateChildren(null,t):l&&!p&&this.updateTextContent(""),null!=o?r!==o&&this.updateTextContent(""+o):null!=a?i!==a&&c.BackendIDOperations.updateInnerHTMLByID(this._rootNodeID,a):null!=u&&this.updateChildren(u,t)},unmountComponent:function(){this.unmountChildren(),l.deleteAllListeners(this._rootNodeID),c.Mixin.unmountComponent.call(this)}},y(o,c.Mixin),y(o,o.Mixin),y(o,d.Mixin),y(o,u),t.exports=o},{"./CSSPropertyOperations":5,"./DOMProperty":11,"./DOMPropertyOperations":12,"./ReactBrowserComponentMixin":30,"./ReactBrowserEventEmitter":31,"./ReactComponent":35,"./ReactMount":65,"./ReactMultiChild":66,"./ReactPerf":69,"./escapeTextForBrowser":115,"./invariant":131,"./keyOf":138,"./merge":141,"./mixInto":144}],44:[function(e,t){"use strict";var n=e("./EventConstants"),r=e("./LocalEventTrapMixin"),o=e("./ReactBrowserComponentMixin"),i=e("./ReactCompositeComponent"),a=e("./ReactDOM"),s=a.form,u=i.createClass({displayName:"ReactDOMForm",mixins:[o,r],render:function(){return this.transferPropsTo(s(null,this.props.children))},componentDidMount:function(){this.trapBubbledEvent(n.topLevelTypes.topReset,"reset"),this.trapBubbledEvent(n.topLevelTypes.topSubmit,"submit")}});t.exports=u},{"./EventConstants":16,"./LocalEventTrapMixin":26,"./ReactBrowserComponentMixin":30,"./ReactCompositeComponent":38,"./ReactDOM":41}],45:[function(e,t){"use strict";var n=e("./CSSPropertyOperations"),r=e("./DOMChildrenOperations"),o=e("./DOMPropertyOperations"),i=e("./ReactMount"),a=e("./ReactPerf"),s=e("./invariant"),u=e("./setInnerHTML"),c={dangerouslySetInnerHTML:"`dangerouslySetInnerHTML` must be set using `updateInnerHTMLByID()`.",style:"`style` must be set using `updateStylesByID()`."},l={updatePropertyByID:a.measure("ReactDOMIDOperations","updatePropertyByID",function(e,t,n){var r=i.getNode(e);s(!c.hasOwnProperty(t)),null!=n?o.setValueForProperty(r,t,n):o.deleteValueForProperty(r,t)}),deletePropertyByID:a.measure("ReactDOMIDOperations","deletePropertyByID",function(e,t,n){var r=i.getNode(e);s(!c.hasOwnProperty(t)),o.deleteValueForProperty(r,t,n)}),updateStylesByID:a.measure("ReactDOMIDOperations","updateStylesByID",function(e,t){var r=i.getNode(e);n.setValueForStyles(r,t)}),updateInnerHTMLByID:a.measure("ReactDOMIDOperations","updateInnerHTMLByID",function(e,t){var n=i.getNode(e);u(n,t)}),updateTextContentByID:a.measure("ReactDOMIDOperations","updateTextContentByID",function(e,t){var n=i.getNode(e);r.updateTextContent(n,t)}),dangerouslyReplaceNodeWithMarkupByID:a.measure("ReactDOMIDOperations","dangerouslyReplaceNodeWithMarkupByID",function(e,t){var n=i.getNode(e);r.dangerouslyReplaceNodeWithMarkup(n,t)}),dangerouslyProcessChildrenUpdates:a.measure("ReactDOMIDOperations","dangerouslyProcessChildrenUpdates",function(e,t){for(var n=0;n<e.length;n++)e[n].parentNode=i.getNode(e[n].parentID);r.processUpdates(e,t)})};t.exports=l},{"./CSSPropertyOperations":5,"./DOMChildrenOperations":10,"./DOMPropertyOperations":12,"./ReactMount":65,"./ReactPerf":69,"./invariant":131,"./setInnerHTML":147}],46:[function(e,t){"use strict";var n=e("./EventConstants"),r=e("./LocalEventTrapMixin"),o=e("./ReactBrowserComponentMixin"),i=e("./ReactCompositeComponent"),a=e("./ReactDOM"),s=a.img,u=i.createClass({displayName:"ReactDOMImg",tagName:"IMG",mixins:[o,r],render:function(){return s(this.props)},componentDidMount:function(){this.trapBubbledEvent(n.topLevelTypes.topLoad,"load"),this.trapBubbledEvent(n.topLevelTypes.topError,"error")}});t.exports=u},{"./EventConstants":16,"./LocalEventTrapMixin":26,"./ReactBrowserComponentMixin":30,"./ReactCompositeComponent":38,"./ReactDOM":41}],47:[function(e,t){"use strict";var n=e("./AutoFocusMixin"),r=e("./DOMPropertyOperations"),o=e("./LinkedValueUtils"),i=e("./ReactBrowserComponentMixin"),a=e("./ReactCompositeComponent"),s=e("./ReactDOM"),u=e("./ReactMount"),c=e("./invariant"),l=e("./merge"),p=s.input,d={},f=a.createClass({displayName:"ReactDOMInput",mixins:[n,o.Mixin,i],getInitialState:function(){var e=this.props.defaultValue;return{checked:this.props.defaultChecked||!1,value:null!=e?e:null}},shouldComponentUpdate:function(){return!this._isChanging},render:function(){var e=l(this.props);e.defaultChecked=null,e.defaultValue=null;var t=o.getValue(this);e.value=null!=t?t:this.state.value;var n=o.getChecked(this);return e.checked=null!=n?n:this.state.checked,e.onChange=this._handleChange,p(e,this.props.children)},componentDidMount:function(){var e=u.getID(this.getDOMNode());d[e]=this},componentWillUnmount:function(){var e=this.getDOMNode(),t=u.getID(e);delete d[t]},componentDidUpdate:function(){var e=this.getDOMNode();null!=this.props.checked&&r.setValueForProperty(e,"checked",this.props.checked||!1);var t=o.getValue(this);null!=t&&r.setValueForProperty(e,"value",""+t)},_handleChange:function(e){var t,n=o.getOnChange(this);n&&(this._isChanging=!0,t=n.call(this,e),this._isChanging=!1),this.setState({checked:e.target.checked,value:e.target.value});var r=this.props.name;if("radio"===this.props.type&&null!=r){for(var i=this.getDOMNode(),a=i;a.parentNode;)a=a.parentNode;for(var s=a.querySelectorAll("input[name="+JSON.stringify(""+r)+'][type="radio"]'),l=0,p=s.length;p>l;l++){var f=s[l];if(f!==i&&f.form===i.form){var h=u.getID(f);c(h);var v=d[h];c(v),v.setState({checked:!1})}}}return t}});t.exports=f},{"./AutoFocusMixin":1,"./DOMPropertyOperations":12,"./LinkedValueUtils":25,"./ReactBrowserComponentMixin":30,"./ReactCompositeComponent":38,"./ReactDOM":41,"./ReactMount":65,"./invariant":131,"./merge":141}],48:[function(e,t){"use strict";var n=e("./ReactBrowserComponentMixin"),r=e("./ReactCompositeComponent"),o=e("./ReactDOM"),i=(e("./warning"),o.option),a=r.createClass({displayName:"ReactDOMOption",mixins:[n],componentWillMount:function(){},render:function(){return i(this.props,this.props.children)}});t.exports=a},{"./ReactBrowserComponentMixin":30,"./ReactCompositeComponent":38,"./ReactDOM":41,"./warning":153}],49:[function(e,t){"use strict";function n(e,t){if(null!=e[t])if(e.multiple){if(!Array.isArray(e[t]))return new Error("The `"+t+"` prop supplied to <select> must be an array if `multiple` is true.")}else if(Array.isArray(e[t]))return new Error("The `"+t+"` prop supplied to <select> must be a scalar value if `multiple` is false.")}function r(e,t){var n,r,o,i=e.props.multiple,a=null!=t?t:e.state.value,s=e.getDOMNode().options;if(i)for(n={},r=0,o=a.length;o>r;++r)n[""+a[r]]=!0;else n=""+a;for(r=0,o=s.length;o>r;r++){var u=i?n.hasOwnProperty(s[r].value):s[r].value===n;u!==s[r].selected&&(s[r].selected=u)}}var o=e("./AutoFocusMixin"),i=e("./LinkedValueUtils"),a=e("./ReactBrowserComponentMixin"),s=e("./ReactCompositeComponent"),u=e("./ReactDOM"),c=e("./merge"),l=u.select,p=s.createClass({displayName:"ReactDOMSelect",mixins:[o,i.Mixin,a],propTypes:{defaultValue:n,value:n},getInitialState:function(){return{value:this.props.defaultValue||(this.props.multiple?[]:"")}
+},componentWillReceiveProps:function(e){!this.props.multiple&&e.multiple?this.setState({value:[this.state.value]}):this.props.multiple&&!e.multiple&&this.setState({value:this.state.value[0]})},shouldComponentUpdate:function(){return!this._isChanging},render:function(){var e=c(this.props);return e.onChange=this._handleChange,e.value=null,l(e,this.props.children)},componentDidMount:function(){r(this,i.getValue(this))},componentDidUpdate:function(e){var t=i.getValue(this),n=!!e.multiple,o=!!this.props.multiple;(null!=t||n!==o)&&r(this,t)},_handleChange:function(e){var t,n=i.getOnChange(this);n&&(this._isChanging=!0,t=n.call(this,e),this._isChanging=!1);var r;if(this.props.multiple){r=[];for(var o=e.target.options,a=0,s=o.length;s>a;a++)o[a].selected&&r.push(o[a].value)}else r=e.target.value;return this.setState({value:r}),t}});t.exports=p},{"./AutoFocusMixin":1,"./LinkedValueUtils":25,"./ReactBrowserComponentMixin":30,"./ReactCompositeComponent":38,"./ReactDOM":41,"./merge":141}],50:[function(e,t){"use strict";function n(e,t,n,r){return e===n&&t===r}function r(e){var t=document.selection,n=t.createRange(),r=n.text.length,o=n.duplicate();o.moveToElementText(e),o.setEndPoint("EndToStart",n);var i=o.text.length,a=i+r;return{start:i,end:a}}function o(e){var t=window.getSelection();if(0===t.rangeCount)return null;var r=t.anchorNode,o=t.anchorOffset,i=t.focusNode,a=t.focusOffset,s=t.getRangeAt(0),u=n(t.anchorNode,t.anchorOffset,t.focusNode,t.focusOffset),c=u?0:s.toString().length,l=s.cloneRange();l.selectNodeContents(e),l.setEnd(s.startContainer,s.startOffset);var p=n(l.startContainer,l.startOffset,l.endContainer,l.endOffset),d=p?0:l.toString().length,f=d+c,h=document.createRange();h.setStart(r,o),h.setEnd(i,a);var v=h.collapsed;return h.detach(),{start:v?f:d,end:v?d:f}}function i(e,t){var n,r,o=document.selection.createRange().duplicate();"undefined"==typeof t.end?(n=t.start,r=n):t.start>t.end?(n=t.end,r=t.start):(n=t.start,r=t.end),o.moveToElementText(e),o.moveStart("character",n),o.setEndPoint("EndToStart",o),o.moveEnd("character",r-n),o.select()}function a(e,t){var n=window.getSelection(),r=e[c()].length,o=Math.min(t.start,r),i="undefined"==typeof t.end?o:Math.min(t.end,r);if(!n.extend&&o>i){var a=i;i=o,o=a}var s=u(e,o),l=u(e,i);if(s&&l){var p=document.createRange();p.setStart(s.node,s.offset),n.removeAllRanges(),o>i?(n.addRange(p),n.extend(l.node,l.offset)):(p.setEnd(l.node,l.offset),n.addRange(p)),p.detach()}}var s=e("./ExecutionEnvironment"),u=e("./getNodeForCharacterOffset"),c=e("./getTextContentAccessor"),l=s.canUseDOM&&document.selection,p={getOffsets:l?r:o,setOffsets:l?i:a};t.exports=p},{"./ExecutionEnvironment":22,"./getNodeForCharacterOffset":124,"./getTextContentAccessor":126}],51:[function(e,t){"use strict";var n=e("./AutoFocusMixin"),r=e("./DOMPropertyOperations"),o=e("./LinkedValueUtils"),i=e("./ReactBrowserComponentMixin"),a=e("./ReactCompositeComponent"),s=e("./ReactDOM"),u=e("./invariant"),c=e("./merge"),l=(e("./warning"),s.textarea),p=a.createClass({displayName:"ReactDOMTextarea",mixins:[n,o.Mixin,i],getInitialState:function(){var e=this.props.defaultValue,t=this.props.children;null!=t&&(u(null==e),Array.isArray(t)&&(u(t.length<=1),t=t[0]),e=""+t),null==e&&(e="");var n=o.getValue(this);return{initialValue:""+(null!=n?n:e)}},shouldComponentUpdate:function(){return!this._isChanging},render:function(){var e=c(this.props);return u(null==e.dangerouslySetInnerHTML),e.defaultValue=null,e.value=null,e.onChange=this._handleChange,l(e,this.state.initialValue)},componentDidUpdate:function(){var e=o.getValue(this);if(null!=e){var t=this.getDOMNode();r.setValueForProperty(t,"value",""+e)}},_handleChange:function(e){var t,n=o.getOnChange(this);return n&&(this._isChanging=!0,t=n.call(this,e),this._isChanging=!1),this.setState({value:e.target.value}),t}});t.exports=p},{"./AutoFocusMixin":1,"./DOMPropertyOperations":12,"./LinkedValueUtils":25,"./ReactBrowserComponentMixin":30,"./ReactCompositeComponent":38,"./ReactDOM":41,"./invariant":131,"./merge":141,"./warning":153}],52:[function(e,t){"use strict";function n(){this.reinitializeTransaction()}var r=e("./ReactUpdates"),o=e("./Transaction"),i=e("./emptyFunction"),a=e("./mixInto"),s={initialize:i,close:function(){p.isBatchingUpdates=!1}},u={initialize:i,close:r.flushBatchedUpdates.bind(r)},c=[u,s];a(n,o.Mixin),a(n,{getTransactionWrappers:function(){return c}});var l=new n,p={isBatchingUpdates:!1,batchedUpdates:function(e,t,n){var r=p.isBatchingUpdates;p.isBatchingUpdates=!0,r?e(t,n):l.perform(e,null,t,n)}};t.exports=p},{"./ReactUpdates":84,"./Transaction":101,"./emptyFunction":113,"./mixInto":144}],53:[function(e,t){"use strict";function n(){x.EventEmitter.injectReactEventListener(D),x.EventPluginHub.injectEventPluginOrder(s),x.EventPluginHub.injectInstanceHandle(b),x.EventPluginHub.injectMount(O),x.EventPluginHub.injectEventPluginsByName({SimpleEventPlugin:S,EnterLeaveEventPlugin:u,ChangeEventPlugin:o,CompositionEventPlugin:a,MobileSafariClickEventPlugin:p,SelectEventPlugin:P,BeforeInputEventPlugin:r}),x.DOM.injectComponentClasses({button:m,form:g,img:y,input:C,option:E,select:R,textarea:M,html:_(v.html),head:_(v.head),body:_(v.body)}),x.CompositeComponent.injectMixin(d),x.DOMProperty.injectDOMPropertyConfig(l),x.DOMProperty.injectDOMPropertyConfig(w),x.EmptyComponent.injectEmptyComponent(v.noscript),x.Updates.injectReconcileTransaction(f.ReactReconcileTransaction),x.Updates.injectBatchingStrategy(h),x.RootIndex.injectCreateReactRootIndex(c.canUseDOM?i.createReactRootIndex:T.createReactRootIndex),x.Component.injectEnvironment(f)}var r=e("./BeforeInputEventPlugin"),o=e("./ChangeEventPlugin"),i=e("./ClientReactRootIndex"),a=e("./CompositionEventPlugin"),s=e("./DefaultEventPluginOrder"),u=e("./EnterLeaveEventPlugin"),c=e("./ExecutionEnvironment"),l=e("./HTMLDOMPropertyConfig"),p=e("./MobileSafariClickEventPlugin"),d=e("./ReactBrowserComponentMixin"),f=e("./ReactComponentBrowserEnvironment"),h=e("./ReactDefaultBatchingStrategy"),v=e("./ReactDOM"),m=e("./ReactDOMButton"),g=e("./ReactDOMForm"),y=e("./ReactDOMImg"),C=e("./ReactDOMInput"),E=e("./ReactDOMOption"),R=e("./ReactDOMSelect"),M=e("./ReactDOMTextarea"),D=e("./ReactEventListener"),x=e("./ReactInjection"),b=e("./ReactInstanceHandles"),O=e("./ReactMount"),P=e("./SelectEventPlugin"),T=e("./ServerReactRootIndex"),S=e("./SimpleEventPlugin"),w=e("./SVGDOMPropertyConfig"),_=e("./createFullPageComponent");t.exports={inject:n}},{"./BeforeInputEventPlugin":2,"./ChangeEventPlugin":7,"./ClientReactRootIndex":8,"./CompositionEventPlugin":9,"./DefaultEventPluginOrder":14,"./EnterLeaveEventPlugin":15,"./ExecutionEnvironment":22,"./HTMLDOMPropertyConfig":23,"./MobileSafariClickEventPlugin":27,"./ReactBrowserComponentMixin":30,"./ReactComponentBrowserEnvironment":36,"./ReactDOM":41,"./ReactDOMButton":42,"./ReactDOMForm":44,"./ReactDOMImg":46,"./ReactDOMInput":47,"./ReactDOMOption":48,"./ReactDOMSelect":49,"./ReactDOMTextarea":51,"./ReactDefaultBatchingStrategy":52,"./ReactEventListener":59,"./ReactInjection":60,"./ReactInstanceHandles":62,"./ReactMount":65,"./SVGDOMPropertyConfig":86,"./SelectEventPlugin":87,"./ServerReactRootIndex":88,"./SimpleEventPlugin":89,"./createFullPageComponent":109}],54:[function(e,t){"use strict";function n(e,t){if("function"==typeof t)for(var n in t)if(t.hasOwnProperty(n)){var r=t[n];if("function"==typeof r){var o=r.bind(t);for(var i in r)r.hasOwnProperty(i)&&(o[i]=r[i]);e[n]=o}else e[n]=r}}var r=e("./ReactContext"),o=e("./ReactCurrentOwner"),i=e("./merge"),a=(e("./warning"),function(){});a.createFactory=function(e){var t=Object.create(a.prototype),s=function(e,n){null==e?e={}:"object"==typeof e&&(e=i(e));var a=arguments.length-1;if(1===a)e.children=n;else if(a>1){for(var s=Array(a),u=0;a>u;u++)s[u]=arguments[u+1];e.children=s}var c=Object.create(t);return c._owner=o.current,c._context=r.current,c.props=e,c};return s.prototype=t,s.type=e,t.type=e,n(s,e),t.constructor=s,s},a.cloneAndReplaceProps=function(e,t){var n=Object.create(e.constructor.prototype);return n._owner=e._owner,n._context=e._context,n.props=t,n},a.isValidFactory=function(e){return"function"==typeof e&&e.prototype instanceof a},a.isValidDescriptor=function(e){return e instanceof a},t.exports=a},{"./ReactContext":39,"./ReactCurrentOwner":40,"./merge":141,"./warning":153}],55:[function(e,t){"use strict";function n(){var e=p.current;return e&&e.constructor.displayName||void 0}function r(e,t){e._store.validated||null!=e.props.key||(e._store.validated=!0,i("react_key_warning",'Each child in an array should have a unique "key" prop.',e,t))}function o(e,t,n){m.test(e)&&i("react_numeric_key_warning","Child objects should have non-numeric keys so ordering is preserved.",t,n)}function i(e,t,r,o){var i=n(),a=o.displayName,s=i||a,u=f[e];if(!u.hasOwnProperty(s)){u[s]=!0,t+=i?" Check the render method of "+i+".":" Check the renderComponent call using <"+a+">.";var c=null;r._owner&&r._owner!==p.current&&(c=r._owner.constructor.displayName,t+=" It was passed a child from "+c+"."),t+=" See http://fb.me/react-warning-keys for more information.",d(e,{component:s,componentOwner:c}),console.warn(t)}}function a(){var e=n()||"";h.hasOwnProperty(e)||(h[e]=!0,d("react_object_map_children"))}function s(e,t){if(Array.isArray(e))for(var n=0;n<e.length;n++){var i=e[n];c.isValidDescriptor(i)&&r(i,t)}else if(c.isValidDescriptor(e))e._store.validated=!0;else if(e&&"object"==typeof e){a();for(var s in e)o(s,e[s],t)}}function u(e,t,n,r){for(var o in t)if(t.hasOwnProperty(o)){var i;try{i=t[o](n,o,e,r)}catch(a){i=a}i instanceof Error&&!(i.message in v)&&(v[i.message]=!0,d("react_failed_descriptor_type_check",{message:i.message}))}}var c=e("./ReactDescriptor"),l=e("./ReactPropTypeLocations"),p=e("./ReactCurrentOwner"),d=e("./monitorCodeUse"),f={react_key_warning:{},react_numeric_key_warning:{}},h={},v={},m=/^\d+$/,g={createFactory:function(e,t,n){var r=function(){for(var r=e.apply(this,arguments),o=1;o<arguments.length;o++)s(arguments[o],r.type);var i=r.type.displayName;return t&&u(i,t,r.props,l.prop),n&&u(i,n,r._context,l.context),r};r.prototype=e.prototype,r.type=e.type;for(var o in e)e.hasOwnProperty(o)&&(r[o]=e[o]);return r}};t.exports=g},{"./ReactCurrentOwner":40,"./ReactDescriptor":54,"./ReactPropTypeLocations":72,"./monitorCodeUse":145}],56:[function(e,t){"use strict";function n(){return s(a),a()}function r(e){u[e]=!0}function o(e){delete u[e]}function i(e){return u[e]}var a,s=e("./invariant"),u={},c={injectEmptyComponent:function(e){a=e}},l={deregisterNullComponentID:o,getEmptyComponent:n,injection:c,isNullComponentID:i,registerNullComponentID:r};t.exports=l},{"./invariant":131}],57:[function(e,t){"use strict";var n={guard:function(e){return e}};t.exports=n},{}],58:[function(e,t){"use strict";function n(e){r.enqueueEvents(e),r.processEventQueue()}var r=e("./EventPluginHub"),o={handleTopLevel:function(e,t,o,i){var a=r.extractEvents(e,t,o,i);n(a)}};t.exports=o},{"./EventPluginHub":18}],59:[function(e,t){"use strict";function n(e){var t=l.getID(e),n=c.getReactRootIDFromNodeID(t),r=l.findReactContainerForID(n),o=l.getFirstReactDOM(r);return o}function r(e,t){this.topLevelType=e,this.nativeEvent=t,this.ancestors=[]}function o(e){for(var t=l.getFirstReactDOM(d(e.nativeEvent))||window,r=t;r;)e.ancestors.push(r),r=n(r);for(var o=0,i=e.ancestors.length;i>o;o++){t=e.ancestors[o];var a=l.getID(t)||"";v._handleTopLevel(e.topLevelType,t,a,e.nativeEvent)}}function i(e){var t=f(window);e(t)}var a=e("./EventListener"),s=e("./ExecutionEnvironment"),u=e("./PooledClass"),c=e("./ReactInstanceHandles"),l=e("./ReactMount"),p=e("./ReactUpdates"),d=e("./getEventTarget"),f=e("./getUnboundedScrollPosition"),h=e("./mixInto");h(r,{destructor:function(){this.topLevelType=null,this.nativeEvent=null,this.ancestors.length=0}}),u.addPoolingTo(r,u.twoArgumentPooler);var v={_enabled:!0,_handleTopLevel:null,WINDOW_HANDLE:s.canUseDOM?window:null,setHandleTopLevel:function(e){v._handleTopLevel=e},setEnabled:function(e){v._enabled=!!e},isEnabled:function(){return v._enabled},trapBubbledEvent:function(e,t,n){var r=n;return r?a.listen(r,t,v.dispatchEvent.bind(null,e)):void 0},trapCapturedEvent:function(e,t,n){var r=n;return r?a.capture(r,t,v.dispatchEvent.bind(null,e)):void 0},monitorScrollValue:function(e){var t=i.bind(null,e);a.listen(window,"scroll",t),a.listen(window,"resize",t)},dispatchEvent:function(e,t){if(v._enabled){var n=r.getPooled(e,t);try{p.batchedUpdates(o,n)}finally{r.release(n)}}}};t.exports=v},{"./EventListener":17,"./ExecutionEnvironment":22,"./PooledClass":28,"./ReactInstanceHandles":62,"./ReactMount":65,"./ReactUpdates":84,"./getEventTarget":122,"./getUnboundedScrollPosition":127,"./mixInto":144}],60:[function(e,t){"use strict";var n=e("./DOMProperty"),r=e("./EventPluginHub"),o=e("./ReactComponent"),i=e("./ReactCompositeComponent"),a=e("./ReactDOM"),s=e("./ReactEmptyComponent"),u=e("./ReactBrowserEventEmitter"),c=e("./ReactPerf"),l=e("./ReactRootIndex"),p=e("./ReactUpdates"),d={Component:o.injection,CompositeComponent:i.injection,DOMProperty:n.injection,EmptyComponent:s.injection,EventPluginHub:r.injection,DOM:a.injection,EventEmitter:u.injection,Perf:c.injection,RootIndex:l.injection,Updates:p.injection};t.exports=d},{"./DOMProperty":11,"./EventPluginHub":18,"./ReactBrowserEventEmitter":31,"./ReactComponent":35,"./ReactCompositeComponent":38,"./ReactDOM":41,"./ReactEmptyComponent":56,"./ReactPerf":69,"./ReactRootIndex":76,"./ReactUpdates":84}],61:[function(e,t){"use strict";function n(e){return o(document.documentElement,e)}var r=e("./ReactDOMSelection"),o=e("./containsNode"),i=e("./focusNode"),a=e("./getActiveElement"),s={hasSelectionCapabilities:function(e){return e&&("INPUT"===e.nodeName&&"text"===e.type||"TEXTAREA"===e.nodeName||"true"===e.contentEditable)},getSelectionInformation:function(){var e=a();return{focusedElem:e,selectionRange:s.hasSelectionCapabilities(e)?s.getSelection(e):null}},restoreSelection:function(e){var t=a(),r=e.focusedElem,o=e.selectionRange;t!==r&&n(r)&&(s.hasSelectionCapabilities(r)&&s.setSelection(r,o),i(r))},getSelection:function(e){var t;if("selectionStart"in e)t={start:e.selectionStart,end:e.selectionEnd};else if(document.selection&&"INPUT"===e.nodeName){var n=document.selection.createRange();n.parentElement()===e&&(t={start:-n.moveStart("character",-e.value.length),end:-n.moveEnd("character",-e.value.length)})}else t=r.getOffsets(e);return t||{start:0,end:0}},setSelection:function(e,t){var n=t.start,o=t.end;if("undefined"==typeof o&&(o=n),"selectionStart"in e)e.selectionStart=n,e.selectionEnd=Math.min(o,e.value.length);else if(document.selection&&"INPUT"===e.nodeName){var i=e.createTextRange();i.collapse(!0),i.moveStart("character",n),i.moveEnd("character",o-n),i.select()}else r.setOffsets(e,t)}};t.exports=s},{"./ReactDOMSelection":50,"./containsNode":106,"./focusNode":117,"./getActiveElement":119}],62:[function(e,t){"use strict";function n(e){return d+e.toString(36)}function r(e,t){return e.charAt(t)===d||t===e.length}function o(e){return""===e||e.charAt(0)===d&&e.charAt(e.length-1)!==d}function i(e,t){return 0===t.indexOf(e)&&r(t,e.length)}function a(e){return e?e.substr(0,e.lastIndexOf(d)):""}function s(e,t){if(p(o(e)&&o(t)),p(i(e,t)),e===t)return e;for(var n=e.length+f,a=n;a<t.length&&!r(t,a);a++);return t.substr(0,a)}function u(e,t){var n=Math.min(e.length,t.length);if(0===n)return"";for(var i=0,a=0;n>=a;a++)if(r(e,a)&&r(t,a))i=a;else if(e.charAt(a)!==t.charAt(a))break;var s=e.substr(0,i);return p(o(s)),s}function c(e,t,n,r,o,u){e=e||"",t=t||"",p(e!==t);var c=i(t,e);p(c||i(e,t));for(var l=0,d=c?a:s,f=e;;f=d(f,t)){var v;if(o&&f===e||u&&f===t||(v=n(f,c,r)),v===!1||f===t)break;p(l++<h)}}var l=e("./ReactRootIndex"),p=e("./invariant"),d=".",f=d.length,h=100,v={createReactRootID:function(){return n(l.createReactRootIndex())},createReactID:function(e,t){return e+t},getReactRootIDFromNodeID:function(e){if(e&&e.charAt(0)===d&&e.length>1){var t=e.indexOf(d,1);return t>-1?e.substr(0,t):e}return null},traverseEnterLeave:function(e,t,n,r,o){var i=u(e,t);i!==e&&c(e,i,n,r,!1,!0),i!==t&&c(i,t,n,o,!0,!1)},traverseTwoPhase:function(e,t,n){e&&(c("",e,t,n,!0,!1),c(e,"",t,n,!1,!0))},traverseAncestors:function(e,t,n){c("",e,t,n,!0,!1)},_getFirstCommonAncestorID:u,_getNextDescendantID:s,isAncestorIDOf:i,SEPARATOR:d};t.exports=v},{"./ReactRootIndex":76,"./invariant":131}],63:[function(e,t){"use strict";function n(e,t){this.value=e,this.requestChange=t}function r(e){var t={value:"undefined"==typeof e?o.PropTypes.any.isRequired:e.isRequired,requestChange:o.PropTypes.func.isRequired};return o.PropTypes.shape(t)}var o=e("./React");n.PropTypes={link:r},t.exports=n},{"./React":29}],64:[function(e,t){"use strict";var n=e("./adler32"),r={CHECKSUM_ATTR_NAME:"data-react-checksum",addChecksumToMarkup:function(e){var t=n(e);return e.replace(">"," "+r.CHECKSUM_ATTR_NAME+'="'+t+'">')},canReuseMarkup:function(e,t){var o=t.getAttribute(r.CHECKSUM_ATTR_NAME);o=o&&parseInt(o,10);var i=n(e);return i===o}};t.exports=r},{"./adler32":104}],65:[function(e,t){"use strict";function n(e){var t=g(e);return t&&w.getID(t)}function r(e){var t=o(e);if(t)if(D.hasOwnProperty(t)){var n=D[t];n!==e&&(C(!s(n,t)),D[t]=e)}else D[t]=e;return t}function o(e){return e&&e.getAttribute&&e.getAttribute(M)||""}function i(e,t){var n=o(e);n!==t&&delete D[n],e.setAttribute(M,t),D[t]=e}function a(e){return D.hasOwnProperty(e)&&s(D[e],e)||(D[e]=w.findReactNodeByID(e)),D[e]}function s(e,t){if(e){C(o(e)===t);var n=w.findReactContainerForID(t);if(n&&m(n,e))return!0}return!1}function u(e){delete D[e]}function c(e){var t=D[e];return t&&s(t,e)?void(S=t):!1}function l(e){S=null,h.traverseAncestors(e,c);var t=S;return S=null,t}var p=e("./DOMProperty"),d=e("./ReactBrowserEventEmitter"),f=(e("./ReactCurrentOwner"),e("./ReactDescriptor")),h=e("./ReactInstanceHandles"),v=e("./ReactPerf"),m=e("./containsNode"),g=e("./getReactRootElementInContainer"),y=e("./instantiateReactComponent"),C=e("./invariant"),E=e("./shouldUpdateReactComponent"),R=(e("./warning"),h.SEPARATOR),M=p.ID_ATTRIBUTE_NAME,D={},x=1,b=9,O={},P={},T=[],S=null,w={_instancesByReactRootID:O,scrollMonitor:function(e,t){t()},_updateRootComponent:function(e,t,n,r){var o=t.props;return w.scrollMonitor(n,function(){e.replaceProps(o,r)}),e},_registerComponent:function(e,t){C(t&&(t.nodeType===x||t.nodeType===b)),d.ensureScrollValueMonitoring();var n=w.registerContainer(t);return O[n]=e,n},_renderNewRootComponent:v.measure("ReactMount","_renderNewRootComponent",function(e,t,n){var r=y(e),o=w._registerComponent(r,t);return r.mountComponentIntoNode(o,t,n),r}),renderComponent:function(e,t,r){C(f.isValidDescriptor(e));var o=O[n(t)];if(o){var i=o._descriptor;if(E(i,e))return w._updateRootComponent(o,e,t,r);w.unmountComponentAtNode(t)}var a=g(t),s=a&&w.isRenderedByReact(a),u=s&&!o,c=w._renderNewRootComponent(e,t,u);return r&&r.call(c),c},constructAndRenderComponent:function(e,t,n){return w.renderComponent(e(t),n)},constructAndRenderComponentByID:function(e,t,n){var r=document.getElementById(n);return C(r),w.constructAndRenderComponent(e,t,r)},registerContainer:function(e){var t=n(e);return t&&(t=h.getReactRootIDFromNodeID(t)),t||(t=h.createReactRootID()),P[t]=e,t},unmountComponentAtNode:function(e){var t=n(e),r=O[t];return r?(w.unmountComponentFromNode(r,e),delete O[t],delete P[t],!0):!1},unmountComponentFromNode:function(e,t){for(e.unmountComponent(),t.nodeType===b&&(t=t.documentElement);t.lastChild;)t.removeChild(t.lastChild)},findReactContainerForID:function(e){var t=h.getReactRootIDFromNodeID(e),n=P[t];return n},findReactNodeByID:function(e){var t=w.findReactContainerForID(e);return w.findComponentRoot(t,e)},isRenderedByReact:function(e){if(1!==e.nodeType)return!1;var t=w.getID(e);return t?t.charAt(0)===R:!1},getFirstReactDOM:function(e){for(var t=e;t&&t.parentNode!==t;){if(w.isRenderedByReact(t))return t;t=t.parentNode}return null},findComponentRoot:function(e,t){var n=T,r=0,o=l(t)||e;for(n[0]=o.firstChild,n.length=1;r<n.length;){for(var i,a=n[r++];a;){var s=w.getID(a);s?t===s?i=a:h.isAncestorIDOf(s,t)&&(n.length=r=0,n.push(a.firstChild)):n.push(a.firstChild),a=a.nextSibling}if(i)return n.length=0,i}n.length=0,C(!1)},getReactRootID:n,getID:r,setID:i,getNode:a,purgeID:u};t.exports=w},{"./DOMProperty":11,"./ReactBrowserEventEmitter":31,"./ReactCurrentOwner":40,"./ReactDescriptor":54,"./ReactInstanceHandles":62,"./ReactPerf":69,"./containsNode":106,"./getReactRootElementInContainer":125,"./instantiateReactComponent":130,"./invariant":131,"./shouldUpdateReactComponent":149,"./warning":153}],66:[function(e,t){"use strict";function n(e,t,n){h.push({parentID:e,parentNode:null,type:c.INSERT_MARKUP,markupIndex:v.push(t)-1,textContent:null,fromIndex:null,toIndex:n})}function r(e,t,n){h.push({parentID:e,parentNode:null,type:c.MOVE_EXISTING,markupIndex:null,textContent:null,fromIndex:t,toIndex:n})}function o(e,t){h.push({parentID:e,parentNode:null,type:c.REMOVE_NODE,markupIndex:null,textContent:null,fromIndex:t,toIndex:null})}function i(e,t){h.push({parentID:e,parentNode:null,type:c.TEXT_CONTENT,markupIndex:null,textContent:t,fromIndex:null,toIndex:null})}function a(){h.length&&(u.BackendIDOperations.dangerouslyProcessChildrenUpdates(h,v),s())}function s(){h.length=0,v.length=0}var u=e("./ReactComponent"),c=e("./ReactMultiChildUpdateTypes"),l=e("./flattenChildren"),p=e("./instantiateReactComponent"),d=e("./shouldUpdateReactComponent"),f=0,h=[],v=[],m={Mixin:{mountChildren:function(e,t){var n=l(e),r=[],o=0;this._renderedChildren=n;for(var i in n){var a=n[i];if(n.hasOwnProperty(i)){var s=p(a);n[i]=s;var u=this._rootNodeID+i,c=s.mountComponent(u,t,this._mountDepth+1);s._mountIndex=o,r.push(c),o++}}return r},updateTextContent:function(e){f++;var t=!0;try{var n=this._renderedChildren;for(var r in n)n.hasOwnProperty(r)&&this._unmountChildByName(n[r],r);this.setTextContent(e),t=!1}finally{f--,f||(t?s():a())}},updateChildren:function(e,t){f++;var n=!0;try{this._updateChildren(e,t),n=!1}finally{f--,f||(n?s():a())}},_updateChildren:function(e,t){var n=l(e),r=this._renderedChildren;if(n||r){var o,i=0,a=0;for(o in n)if(n.hasOwnProperty(o)){var s=r&&r[o],u=s&&s._descriptor,c=n[o];if(d(u,c))this.moveChild(s,a,i),i=Math.max(s._mountIndex,i),s.receiveComponent(c,t),s._mountIndex=a;else{s&&(i=Math.max(s._mountIndex,i),this._unmountChildByName(s,o));var f=p(c);this._mountChildByNameAtIndex(f,o,a,t)}a++}for(o in r)!r.hasOwnProperty(o)||n&&n[o]||this._unmountChildByName(r[o],o)}},unmountChildren:function(){var e=this._renderedChildren;for(var t in e){var n=e[t];n.unmountComponent&&n.unmountComponent()}this._renderedChildren=null},moveChild:function(e,t,n){e._mountIndex<n&&r(this._rootNodeID,e._mountIndex,t)},createChild:function(e,t){n(this._rootNodeID,t,e._mountIndex)},removeChild:function(e){o(this._rootNodeID,e._mountIndex)},setTextContent:function(e){i(this._rootNodeID,e)},_mountChildByNameAtIndex:function(e,t,n,r){var o=this._rootNodeID+t,i=e.mountComponent(o,r,this._mountDepth+1);e._mountIndex=n,this.createChild(e,i),this._renderedChildren=this._renderedChildren||{},this._renderedChildren[t]=e},_unmountChildByName:function(e,t){this.removeChild(e),e._mountIndex=null,e.unmountComponent(),delete this._renderedChildren[t]}}};t.exports=m},{"./ReactComponent":35,"./ReactMultiChildUpdateTypes":67,"./flattenChildren":116,"./instantiateReactComponent":130,"./shouldUpdateReactComponent":149}],67:[function(e,t){"use strict";var n=e("./keyMirror"),r=n({INSERT_MARKUP:null,MOVE_EXISTING:null,REMOVE_NODE:null,TEXT_CONTENT:null});t.exports=r},{"./keyMirror":137}],68:[function(e,t){"use strict";var n=e("./emptyObject"),r=e("./invariant"),o={isValidOwner:function(e){return!(!e||"function"!=typeof e.attachRef||"function"!=typeof e.detachRef)},addComponentAsRefTo:function(e,t,n){r(o.isValidOwner(n)),n.attachRef(t,e)},removeComponentAsRefFrom:function(e,t,n){r(o.isValidOwner(n)),n.refs[t]===e&&n.detachRef(t)},Mixin:{construct:function(){this.refs=n},attachRef:function(e,t){r(t.isOwnedBy(this));var o=this.refs===n?this.refs={}:this.refs;o[e]=t},detachRef:function(e){delete this.refs[e]}}};t.exports=o},{"./emptyObject":114,"./invariant":131}],69:[function(e,t){"use strict";function n(e,t,n){return n}var r={enableMeasure:!1,storedMeasure:n,measure:function(e,t,n){return n},injection:{injectMeasure:function(e){r.storedMeasure=e}}};t.exports=r},{}],70:[function(e,t){"use strict";function n(e){return function(t,n,r){t[n]=t.hasOwnProperty(n)?e(t[n],r):r}}function r(e,t){for(var n in t)if(t.hasOwnProperty(n)){var r=c[n];r&&c.hasOwnProperty(n)?r(e,n,t[n]):e.hasOwnProperty(n)||(e[n]=t[n])}return e}var o=e("./emptyFunction"),i=e("./invariant"),a=e("./joinClasses"),s=e("./merge"),u=n(function(e,t){return s(t,e)}),c={children:o,className:n(a),key:o,ref:o,style:u},l={TransferStrategies:c,mergeProps:function(e,t){return r(s(e),t)},Mixin:{transferPropsTo:function(e){return i(e._owner===this),r(e.props,this.props),e}}};t.exports=l},{"./emptyFunction":113,"./invariant":131,"./joinClasses":136,"./merge":141}],71:[function(e,t){"use strict";var n={};t.exports=n},{}],72:[function(e,t){"use strict";var n=e("./keyMirror"),r=n({prop:null,context:null,childContext:null});t.exports=r},{"./keyMirror":137}],73:[function(e,t){"use strict";function n(e){function t(t,n,r,o,i){if(o=o||C,null!=n[r])return e(n,r,o,i);var a=g[i];return t?new Error("Required "+a+" `"+r+"` was not specified in "+("`"+o+"`.")):void 0}var n=t.bind(null,!1);return n.isRequired=t.bind(null,!0),n}function r(e){function t(t,n,r,o){var i=t[n],a=h(i);if(a!==e){var s=g[o],u=v(i);return new Error("Invalid "+s+" `"+n+"` of type `"+u+"` "+("supplied to `"+r+"`, expected `"+e+"`."))}}return n(t)}function o(){return n(y.thatReturns())}function i(e){function t(t,n,r,o){var i=t[n];if(!Array.isArray(i)){var a=g[o],s=h(i);return new Error("Invalid "+a+" `"+n+"` of type "+("`"+s+"` supplied to `"+r+"`, expected an array."))}for(var u=0;u<i.length;u++){var c=e(i,u,r,o);if(c instanceof Error)return c}}return n(t)}function a(){function e(e,t,n,r){if(!m.isValidDescriptor(e[t])){var o=g[r];return new Error("Invalid "+o+" `"+t+"` supplied to "+("`"+n+"`, expected a React component."))}}return n(e)}function s(e){function t(t,n,r,o){if(!(t[n]instanceof e)){var i=g[o],a=e.name||C;return new Error("Invalid "+i+" `"+n+"` supplied to "+("`"+r+"`, expected instance of `"+a+"`."))}}return n(t)}function u(e){function t(t,n,r,o){for(var i=t[n],a=0;a<e.length;a++)if(i===e[a])return;var s=g[o],u=JSON.stringify(e);return new Error("Invalid "+s+" `"+n+"` of value `"+i+"` "+("supplied to `"+r+"`, expected one of "+u+"."))}return n(t)}function c(e){function t(t,n,r,o){var i=t[n],a=h(i);if("object"!==a){var s=g[o];return new Error("Invalid "+s+" `"+n+"` of type "+("`"+a+"` supplied to `"+r+"`, expected an object."))}for(var u in i)if(i.hasOwnProperty(u)){var c=e(i,u,r,o);if(c instanceof Error)return c}}return n(t)}function l(e){function t(t,n,r,o){for(var i=0;i<e.length;i++){var a=e[i];if(null==a(t,n,r,o))return}var s=g[o];return new Error("Invalid "+s+" `"+n+"` supplied to "+("`"+r+"`."))}return n(t)}function p(){function e(e,t,n,r){if(!f(e[t])){var o=g[r];return new Error("Invalid "+o+" `"+t+"` supplied to "+("`"+n+"`, expected a renderable prop."))}}return n(e)}function d(e){function t(t,n,r,o){var i=t[n],a=h(i);if("object"!==a){var s=g[o];return new Error("Invalid "+s+" `"+n+"` of type `"+a+"` "+("supplied to `"+r+"`, expected `object`."))}for(var u in e){var c=e[u];if(c){var l=c(i,u,r,o);if(l)return l}}}return n(t,"expected `object`")}function f(e){switch(typeof e){case"number":case"string":return!0;case"boolean":return!e;case"object":if(Array.isArray(e))return e.every(f);if(m.isValidDescriptor(e))return!0;for(var t in e)if(!f(e[t]))return!1;return!0;default:return!1}}function h(e){var t=typeof e;return Array.isArray(e)?"array":e instanceof RegExp?"object":t}function v(e){var t=h(e);if("object"===t){if(e instanceof Date)return"date";if(e instanceof RegExp)return"regexp"}return t}var m=e("./ReactDescriptor"),g=e("./ReactPropTypeLocationNames"),y=e("./emptyFunction"),C="<<anonymous>>",E={array:r("array"),bool:r("boolean"),func:r("function"),number:r("number"),object:r("object"),string:r("string"),any:o(),arrayOf:i,component:a(),instanceOf:s,objectOf:c,oneOf:u,oneOfType:l,renderable:p(),shape:d};t.exports=E},{"./ReactDescriptor":54,"./ReactPropTypeLocationNames":71,"./emptyFunction":113}],74:[function(e,t){"use strict";function n(){this.listenersToPut=[]}var r=e("./PooledClass"),o=e("./ReactBrowserEventEmitter"),i=e("./mixInto");i(n,{enqueuePutListener:function(e,t,n){this.listenersToPut.push({rootNodeID:e,propKey:t,propValue:n})},putListeners:function(){for(var e=0;e<this.listenersToPut.length;e++){var t=this.listenersToPut[e];o.putListener(t.rootNodeID,t.propKey,t.propValue)}},reset:function(){this.listenersToPut.length=0},destructor:function(){this.reset()}}),r.addPoolingTo(n),t.exports=n},{"./PooledClass":28,"./ReactBrowserEventEmitter":31,"./mixInto":144}],75:[function(e,t){"use strict";function n(){this.reinitializeTransaction(),this.renderToStaticMarkup=!1,this.reactMountReady=r.getPooled(null),this.putListenerQueue=s.getPooled()}var r=e("./CallbackQueue"),o=e("./PooledClass"),i=e("./ReactBrowserEventEmitter"),a=e("./ReactInputSelection"),s=e("./ReactPutListenerQueue"),u=e("./Transaction"),c=e("./mixInto"),l={initialize:a.getSelectionInformation,close:a.restoreSelection},p={initialize:function(){var e=i.isEnabled();return i.setEnabled(!1),e},close:function(e){i.setEnabled(e)}},d={initialize:function(){this.reactMountReady.reset()},close:function(){this.reactMountReady.notifyAll()}},f={initialize:function(){this.putListenerQueue.reset()},close:function(){this.putListenerQueue.putListeners()}},h=[f,l,p,d],v={getTransactionWrappers:function(){return h},getReactMountReady:function(){return this.reactMountReady},getPutListenerQueue:function(){return this.putListenerQueue},destructor:function(){r.release(this.reactMountReady),this.reactMountReady=null,s.release(this.putListenerQueue),this.putListenerQueue=null}};c(n,u.Mixin),c(n,v),o.addPoolingTo(n),t.exports=n},{"./CallbackQueue":6,"./PooledClass":28,"./ReactBrowserEventEmitter":31,"./ReactInputSelection":61,"./ReactPutListenerQueue":74,"./Transaction":101,"./mixInto":144}],76:[function(e,t){"use strict";var n={injectCreateReactRootIndex:function(e){r.createReactRootIndex=e}},r={createReactRootIndex:null,injection:n};t.exports=r},{}],77:[function(e,t){"use strict";function n(e){c(o.isValidDescriptor(e)),c(!(2===arguments.length&&"function"==typeof arguments[1]));var t;try{var n=i.createReactRootID();return t=s.getPooled(!1),t.perform(function(){var r=u(e),o=r.mountComponent(n,t,0);return a.addChecksumToMarkup(o)},null)}finally{s.release(t)}}function r(e){c(o.isValidDescriptor(e));var t;try{var n=i.createReactRootID();return t=s.getPooled(!0),t.perform(function(){var r=u(e);return r.mountComponent(n,t,0)},null)}finally{s.release(t)}}var o=e("./ReactDescriptor"),i=e("./ReactInstanceHandles"),a=e("./ReactMarkupChecksum"),s=e("./ReactServerRenderingTransaction"),u=e("./instantiateReactComponent"),c=e("./invariant");t.exports={renderComponentToString:n,renderComponentToStaticMarkup:r}},{"./ReactDescriptor":54,"./ReactInstanceHandles":62,"./ReactMarkupChecksum":64,"./ReactServerRenderingTransaction":78,"./instantiateReactComponent":130,"./invariant":131}],78:[function(e,t){"use strict";function n(e){this.reinitializeTransaction(),this.renderToStaticMarkup=e,this.reactMountReady=o.getPooled(null),this.putListenerQueue=i.getPooled()}var r=e("./PooledClass"),o=e("./CallbackQueue"),i=e("./ReactPutListenerQueue"),a=e("./Transaction"),s=e("./emptyFunction"),u=e("./mixInto"),c={initialize:function(){this.reactMountReady.reset()},close:s},l={initialize:function(){this.putListenerQueue.reset()},close:s},p=[l,c],d={getTransactionWrappers:function(){return p},getReactMountReady:function(){return this.reactMountReady},getPutListenerQueue:function(){return this.putListenerQueue},destructor:function(){o.release(this.reactMountReady),this.reactMountReady=null,i.release(this.putListenerQueue),this.putListenerQueue=null
+}};u(n,a.Mixin),u(n,d),r.addPoolingTo(n),t.exports=n},{"./CallbackQueue":6,"./PooledClass":28,"./ReactPutListenerQueue":74,"./Transaction":101,"./emptyFunction":113,"./mixInto":144}],79:[function(e,t){"use strict";function n(e,t){var n={};return function(r){n[t]=r,e.setState(n)}}var r={createStateSetter:function(e,t){return function(n,r,o,i,a,s){var u=t.call(e,n,r,o,i,a,s);u&&e.setState(u)}},createStateKeySetter:function(e,t){var r=e.__keySetters||(e.__keySetters={});return r[t]||(r[t]=n(e,t))}};r.Mixin={createStateSetter:function(e){return r.createStateSetter(this,e)},createStateKeySetter:function(e){return r.createStateKeySetter(this,e)}},t.exports=r},{}],80:[function(e,t){"use strict";var n=e("./DOMPropertyOperations"),r=e("./ReactBrowserComponentMixin"),o=e("./ReactComponent"),i=e("./ReactDescriptor"),a=e("./escapeTextForBrowser"),s=e("./mixInto"),u=function(e){this.construct(e)};s(u,o.Mixin),s(u,r),s(u,{mountComponent:function(e,t,r){o.Mixin.mountComponent.call(this,e,t,r);var i=a(this.props);return t.renderToStaticMarkup?i:"<span "+n.createMarkupForID(e)+">"+i+"</span>"},receiveComponent:function(e){var t=e.props;t!==this.props&&(this.props=t,o.BackendIDOperations.updateTextContentByID(this._rootNodeID,t))}}),t.exports=i.createFactory(u)},{"./DOMPropertyOperations":12,"./ReactBrowserComponentMixin":30,"./ReactComponent":35,"./ReactDescriptor":54,"./escapeTextForBrowser":115,"./mixInto":144}],81:[function(e,t){"use strict";var n=e("./ReactChildren"),r={getChildMapping:function(e){return n.map(e,function(e){return e})},mergeChildMappings:function(e,t){function n(n){return t.hasOwnProperty(n)?t[n]:e[n]}e=e||{},t=t||{};var r={},o=[];for(var i in e)t.hasOwnProperty(i)?o.length&&(r[i]=o,o=[]):o.push(i);var a,s={};for(var u in t){if(r.hasOwnProperty(u))for(a=0;a<r[u].length;a++){var c=r[u][a];s[r[u][a]]=n(c)}s[u]=n(u)}for(a=0;a<o.length;a++)s[o[a]]=n(o[a]);return s}};t.exports=r},{"./ReactChildren":34}],82:[function(e,t){"use strict";function n(){var e=document.createElement("div"),t=e.style;"AnimationEvent"in window||delete a.animationend.animation,"TransitionEvent"in window||delete a.transitionend.transition;for(var n in a){var r=a[n];for(var o in r)if(o in t){s.push(r[o]);break}}}function r(e,t,n){e.addEventListener(t,n,!1)}function o(e,t,n){e.removeEventListener(t,n,!1)}var i=e("./ExecutionEnvironment"),a={transitionend:{transition:"transitionend",WebkitTransition:"webkitTransitionEnd",MozTransition:"mozTransitionEnd",OTransition:"oTransitionEnd",msTransition:"MSTransitionEnd"},animationend:{animation:"animationend",WebkitAnimation:"webkitAnimationEnd",MozAnimation:"mozAnimationEnd",OAnimation:"oAnimationEnd",msAnimation:"MSAnimationEnd"}},s=[];i.canUseDOM&&n();var u={addEndEventListener:function(e,t){return 0===s.length?void window.setTimeout(t,0):void s.forEach(function(n){r(e,n,t)})},removeEndEventListener:function(e,t){0!==s.length&&s.forEach(function(n){o(e,n,t)})}};t.exports=u},{"./ExecutionEnvironment":22}],83:[function(e,t){"use strict";var n=e("./React"),r=e("./ReactTransitionChildMapping"),o=e("./cloneWithProps"),i=e("./emptyFunction"),a=e("./merge"),s=n.createClass({displayName:"ReactTransitionGroup",propTypes:{component:n.PropTypes.func,childFactory:n.PropTypes.func},getDefaultProps:function(){return{component:n.DOM.span,childFactory:i.thatReturnsArgument}},getInitialState:function(){return{children:r.getChildMapping(this.props.children)}},componentWillReceiveProps:function(e){var t=r.getChildMapping(e.children),n=this.state.children;this.setState({children:r.mergeChildMappings(n,t)});var o;for(o in t){var i=n&&n.hasOwnProperty(o);!t[o]||i||this.currentlyTransitioningKeys[o]||this.keysToEnter.push(o)}for(o in n){var a=t&&t.hasOwnProperty(o);!n[o]||a||this.currentlyTransitioningKeys[o]||this.keysToLeave.push(o)}},componentWillMount:function(){this.currentlyTransitioningKeys={},this.keysToEnter=[],this.keysToLeave=[]},componentDidUpdate:function(){var e=this.keysToEnter;this.keysToEnter=[],e.forEach(this.performEnter);var t=this.keysToLeave;this.keysToLeave=[],t.forEach(this.performLeave)},performEnter:function(e){this.currentlyTransitioningKeys[e]=!0;var t=this.refs[e];t.componentWillEnter?t.componentWillEnter(this._handleDoneEntering.bind(this,e)):this._handleDoneEntering(e)},_handleDoneEntering:function(e){var t=this.refs[e];t.componentDidEnter&&t.componentDidEnter(),delete this.currentlyTransitioningKeys[e];var n=r.getChildMapping(this.props.children);n&&n.hasOwnProperty(e)||this.performLeave(e)},performLeave:function(e){this.currentlyTransitioningKeys[e]=!0;var t=this.refs[e];t.componentWillLeave?t.componentWillLeave(this._handleDoneLeaving.bind(this,e)):this._handleDoneLeaving(e)},_handleDoneLeaving:function(e){var t=this.refs[e];t.componentDidLeave&&t.componentDidLeave(),delete this.currentlyTransitioningKeys[e];var n=r.getChildMapping(this.props.children);if(n&&n.hasOwnProperty(e))this.performEnter(e);else{var o=a(this.state.children);delete o[e],this.setState({children:o})}},render:function(){var e={};for(var t in this.state.children){var n=this.state.children[t];n&&(e[t]=o(this.props.childFactory(n),{ref:t}))}return this.transferPropsTo(this.props.component(null,e))}});t.exports=s},{"./React":29,"./ReactTransitionChildMapping":81,"./cloneWithProps":105,"./emptyFunction":113,"./merge":141}],84:[function(e,t){"use strict";function n(){d(R.ReactReconcileTransaction&&v)}function r(){this.reinitializeTransaction(),this.dirtyComponentsLength=null,this.callbackQueue=u.getPooled(null),this.reconcileTransaction=R.ReactReconcileTransaction.getPooled()}function o(e,t,r){n(),v.batchedUpdates(e,t,r)}function i(e,t){return e._mountDepth-t._mountDepth}function a(e){var t=e.dirtyComponentsLength;d(t===h.length),h.sort(i);for(var n=0;t>n;n++){var r=h[n];if(r.isMounted()){var o=r._pendingCallbacks;if(r._pendingCallbacks=null,r.performUpdateIfNecessary(e.reconcileTransaction),o)for(var a=0;a<o.length;a++)e.callbackQueue.enqueue(o[a],r)}}}function s(e,t){return d(!t||"function"==typeof t),n(),v.isBatchingUpdates?(h.push(e),void(t&&(e._pendingCallbacks?e._pendingCallbacks.push(t):e._pendingCallbacks=[t]))):void v.batchedUpdates(s,e,t)}var u=e("./CallbackQueue"),c=e("./PooledClass"),l=(e("./ReactCurrentOwner"),e("./ReactPerf")),p=e("./Transaction"),d=e("./invariant"),f=e("./mixInto"),h=(e("./warning"),[]),v=null,m={initialize:function(){this.dirtyComponentsLength=h.length},close:function(){this.dirtyComponentsLength!==h.length?(h.splice(0,this.dirtyComponentsLength),C()):h.length=0}},g={initialize:function(){this.callbackQueue.reset()},close:function(){this.callbackQueue.notifyAll()}},y=[m,g];f(r,p.Mixin),f(r,{getTransactionWrappers:function(){return y},destructor:function(){this.dirtyComponentsLength=null,u.release(this.callbackQueue),this.callbackQueue=null,R.ReactReconcileTransaction.release(this.reconcileTransaction),this.reconcileTransaction=null},perform:function(e,t,n){return p.Mixin.perform.call(this,this.reconcileTransaction.perform,this.reconcileTransaction,e,t,n)}}),c.addPoolingTo(r);var C=l.measure("ReactUpdates","flushBatchedUpdates",function(){for(;h.length;){var e=r.getPooled();e.perform(a,null,e),r.release(e)}}),E={injectReconcileTransaction:function(e){d(e),R.ReactReconcileTransaction=e},injectBatchingStrategy:function(e){d(e),d("function"==typeof e.batchedUpdates),d("boolean"==typeof e.isBatchingUpdates),v=e}},R={ReactReconcileTransaction:null,batchedUpdates:o,enqueueUpdate:s,flushBatchedUpdates:C,injection:E};t.exports=R},{"./CallbackQueue":6,"./PooledClass":28,"./ReactCurrentOwner":40,"./ReactPerf":69,"./Transaction":101,"./invariant":131,"./mixInto":144,"./warning":153}],85:[function(e,t){"use strict";var n=e("./LinkedStateMixin"),r=e("./React"),o=e("./ReactComponentWithPureRenderMixin"),i=e("./ReactCSSTransitionGroup"),a=e("./ReactTransitionGroup"),s=e("./cx"),u=e("./cloneWithProps"),c=e("./update");r.addons={CSSTransitionGroup:i,LinkedStateMixin:n,PureRenderMixin:o,TransitionGroup:a,classSet:s,cloneWithProps:u,update:c},t.exports=r},{"./LinkedStateMixin":24,"./React":29,"./ReactCSSTransitionGroup":32,"./ReactComponentWithPureRenderMixin":37,"./ReactTransitionGroup":83,"./cloneWithProps":105,"./cx":111,"./update":152}],86:[function(e,t){"use strict";var n=e("./DOMProperty"),r=n.injection.MUST_USE_ATTRIBUTE,o={Properties:{cx:r,cy:r,d:r,dx:r,dy:r,fill:r,fillOpacity:r,fontFamily:r,fontSize:r,fx:r,fy:r,gradientTransform:r,gradientUnits:r,markerEnd:r,markerMid:r,markerStart:r,offset:r,opacity:r,patternContentUnits:r,patternUnits:r,points:r,preserveAspectRatio:r,r:r,rx:r,ry:r,spreadMethod:r,stopColor:r,stopOpacity:r,stroke:r,strokeDasharray:r,strokeLinecap:r,strokeOpacity:r,strokeWidth:r,textAnchor:r,transform:r,version:r,viewBox:r,x1:r,x2:r,x:r,y1:r,y2:r,y:r},DOMAttributeNames:{fillOpacity:"fill-opacity",fontFamily:"font-family",fontSize:"font-size",gradientTransform:"gradientTransform",gradientUnits:"gradientUnits",markerEnd:"marker-end",markerMid:"marker-mid",markerStart:"marker-start",patternContentUnits:"patternContentUnits",patternUnits:"patternUnits",preserveAspectRatio:"preserveAspectRatio",spreadMethod:"spreadMethod",stopColor:"stop-color",stopOpacity:"stop-opacity",strokeDasharray:"stroke-dasharray",strokeLinecap:"stroke-linecap",strokeOpacity:"stroke-opacity",strokeWidth:"stroke-width",textAnchor:"text-anchor",viewBox:"viewBox"}};t.exports=o},{"./DOMProperty":11}],87:[function(e,t){"use strict";function n(e){if("selectionStart"in e&&a.hasSelectionCapabilities(e))return{start:e.selectionStart,end:e.selectionEnd};if(document.selection){var t=document.selection.createRange();return{parentElement:t.parentElement(),text:t.text,top:t.boundingTop,left:t.boundingLeft}}var n=window.getSelection();return{anchorNode:n.anchorNode,anchorOffset:n.anchorOffset,focusNode:n.focusNode,focusOffset:n.focusOffset}}function r(e){if(!g&&null!=h&&h==u()){var t=n(h);if(!m||!p(m,t)){m=t;var r=s.getPooled(f.select,v,e);return r.type="select",r.target=h,i.accumulateTwoPhaseDispatches(r),r}}}var o=e("./EventConstants"),i=e("./EventPropagators"),a=e("./ReactInputSelection"),s=e("./SyntheticEvent"),u=e("./getActiveElement"),c=e("./isTextInputElement"),l=e("./keyOf"),p=e("./shallowEqual"),d=o.topLevelTypes,f={select:{phasedRegistrationNames:{bubbled:l({onSelect:null}),captured:l({onSelectCapture:null})},dependencies:[d.topBlur,d.topContextMenu,d.topFocus,d.topKeyDown,d.topMouseDown,d.topMouseUp,d.topSelectionChange]}},h=null,v=null,m=null,g=!1,y={eventTypes:f,extractEvents:function(e,t,n,o){switch(e){case d.topFocus:(c(t)||"true"===t.contentEditable)&&(h=t,v=n,m=null);break;case d.topBlur:h=null,v=null,m=null;break;case d.topMouseDown:g=!0;break;case d.topContextMenu:case d.topMouseUp:return g=!1,r(o);case d.topSelectionChange:case d.topKeyDown:case d.topKeyUp:return r(o)}}};t.exports=y},{"./EventConstants":16,"./EventPropagators":21,"./ReactInputSelection":61,"./SyntheticEvent":93,"./getActiveElement":119,"./isTextInputElement":134,"./keyOf":138,"./shallowEqual":148}],88:[function(e,t){"use strict";var n=Math.pow(2,53),r={createReactRootIndex:function(){return Math.ceil(Math.random()*n)}};t.exports=r},{}],89:[function(e,t){"use strict";var n=e("./EventConstants"),r=e("./EventPluginUtils"),o=e("./EventPropagators"),i=e("./SyntheticClipboardEvent"),a=e("./SyntheticEvent"),s=e("./SyntheticFocusEvent"),u=e("./SyntheticKeyboardEvent"),c=e("./SyntheticMouseEvent"),l=e("./SyntheticDragEvent"),p=e("./SyntheticTouchEvent"),d=e("./SyntheticUIEvent"),f=e("./SyntheticWheelEvent"),h=e("./invariant"),v=e("./keyOf"),m=n.topLevelTypes,g={blur:{phasedRegistrationNames:{bubbled:v({onBlur:!0}),captured:v({onBlurCapture:!0})}},click:{phasedRegistrationNames:{bubbled:v({onClick:!0}),captured:v({onClickCapture:!0})}},contextMenu:{phasedRegistrationNames:{bubbled:v({onContextMenu:!0}),captured:v({onContextMenuCapture:!0})}},copy:{phasedRegistrationNames:{bubbled:v({onCopy:!0}),captured:v({onCopyCapture:!0})}},cut:{phasedRegistrationNames:{bubbled:v({onCut:!0}),captured:v({onCutCapture:!0})}},doubleClick:{phasedRegistrationNames:{bubbled:v({onDoubleClick:!0}),captured:v({onDoubleClickCapture:!0})}},drag:{phasedRegistrationNames:{bubbled:v({onDrag:!0}),captured:v({onDragCapture:!0})}},dragEnd:{phasedRegistrationNames:{bubbled:v({onDragEnd:!0}),captured:v({onDragEndCapture:!0})}},dragEnter:{phasedRegistrationNames:{bubbled:v({onDragEnter:!0}),captured:v({onDragEnterCapture:!0})}},dragExit:{phasedRegistrationNames:{bubbled:v({onDragExit:!0}),captured:v({onDragExitCapture:!0})}},dragLeave:{phasedRegistrationNames:{bubbled:v({onDragLeave:!0}),captured:v({onDragLeaveCapture:!0})}},dragOver:{phasedRegistrationNames:{bubbled:v({onDragOver:!0}),captured:v({onDragOverCapture:!0})}},dragStart:{phasedRegistrationNames:{bubbled:v({onDragStart:!0}),captured:v({onDragStartCapture:!0})}},drop:{phasedRegistrationNames:{bubbled:v({onDrop:!0}),captured:v({onDropCapture:!0})}},focus:{phasedRegistrationNames:{bubbled:v({onFocus:!0}),captured:v({onFocusCapture:!0})}},input:{phasedRegistrationNames:{bubbled:v({onInput:!0}),captured:v({onInputCapture:!0})}},keyDown:{phasedRegistrationNames:{bubbled:v({onKeyDown:!0}),captured:v({onKeyDownCapture:!0})}},keyPress:{phasedRegistrationNames:{bubbled:v({onKeyPress:!0}),captured:v({onKeyPressCapture:!0})}},keyUp:{phasedRegistrationNames:{bubbled:v({onKeyUp:!0}),captured:v({onKeyUpCapture:!0})}},load:{phasedRegistrationNames:{bubbled:v({onLoad:!0}),captured:v({onLoadCapture:!0})}},error:{phasedRegistrationNames:{bubbled:v({onError:!0}),captured:v({onErrorCapture:!0})}},mouseDown:{phasedRegistrationNames:{bubbled:v({onMouseDown:!0}),captured:v({onMouseDownCapture:!0})}},mouseMove:{phasedRegistrationNames:{bubbled:v({onMouseMove:!0}),captured:v({onMouseMoveCapture:!0})}},mouseOut:{phasedRegistrationNames:{bubbled:v({onMouseOut:!0}),captured:v({onMouseOutCapture:!0})}},mouseOver:{phasedRegistrationNames:{bubbled:v({onMouseOver:!0}),captured:v({onMouseOverCapture:!0})}},mouseUp:{phasedRegistrationNames:{bubbled:v({onMouseUp:!0}),captured:v({onMouseUpCapture:!0})}},paste:{phasedRegistrationNames:{bubbled:v({onPaste:!0}),captured:v({onPasteCapture:!0})}},reset:{phasedRegistrationNames:{bubbled:v({onReset:!0}),captured:v({onResetCapture:!0})}},scroll:{phasedRegistrationNames:{bubbled:v({onScroll:!0}),captured:v({onScrollCapture:!0})}},submit:{phasedRegistrationNames:{bubbled:v({onSubmit:!0}),captured:v({onSubmitCapture:!0})}},touchCancel:{phasedRegistrationNames:{bubbled:v({onTouchCancel:!0}),captured:v({onTouchCancelCapture:!0})}},touchEnd:{phasedRegistrationNames:{bubbled:v({onTouchEnd:!0}),captured:v({onTouchEndCapture:!0})}},touchMove:{phasedRegistrationNames:{bubbled:v({onTouchMove:!0}),captured:v({onTouchMoveCapture:!0})}},touchStart:{phasedRegistrationNames:{bubbled:v({onTouchStart:!0}),captured:v({onTouchStartCapture:!0})}},wheel:{phasedRegistrationNames:{bubbled:v({onWheel:!0}),captured:v({onWheelCapture:!0})}}},y={topBlur:g.blur,topClick:g.click,topContextMenu:g.contextMenu,topCopy:g.copy,topCut:g.cut,topDoubleClick:g.doubleClick,topDrag:g.drag,topDragEnd:g.dragEnd,topDragEnter:g.dragEnter,topDragExit:g.dragExit,topDragLeave:g.dragLeave,topDragOver:g.dragOver,topDragStart:g.dragStart,topDrop:g.drop,topError:g.error,topFocus:g.focus,topInput:g.input,topKeyDown:g.keyDown,topKeyPress:g.keyPress,topKeyUp:g.keyUp,topLoad:g.load,topMouseDown:g.mouseDown,topMouseMove:g.mouseMove,topMouseOut:g.mouseOut,topMouseOver:g.mouseOver,topMouseUp:g.mouseUp,topPaste:g.paste,topReset:g.reset,topScroll:g.scroll,topSubmit:g.submit,topTouchCancel:g.touchCancel,topTouchEnd:g.touchEnd,topTouchMove:g.touchMove,topTouchStart:g.touchStart,topWheel:g.wheel};for(var C in y)y[C].dependencies=[C];var E={eventTypes:g,executeDispatch:function(e,t,n){var o=r.executeDispatch(e,t,n);o===!1&&(e.stopPropagation(),e.preventDefault())},extractEvents:function(e,t,n,r){var v=y[e];if(!v)return null;var g;switch(e){case m.topInput:case m.topLoad:case m.topError:case m.topReset:case m.topSubmit:g=a;break;case m.topKeyPress:if(0===r.charCode)return null;case m.topKeyDown:case m.topKeyUp:g=u;break;case m.topBlur:case m.topFocus:g=s;break;case m.topClick:if(2===r.button)return null;case m.topContextMenu:case m.topDoubleClick:case m.topMouseDown:case m.topMouseMove:case m.topMouseOut:case m.topMouseOver:case m.topMouseUp:g=c;break;case m.topDrag:case m.topDragEnd:case m.topDragEnter:case m.topDragExit:case m.topDragLeave:case m.topDragOver:case m.topDragStart:case m.topDrop:g=l;break;case m.topTouchCancel:case m.topTouchEnd:case m.topTouchMove:case m.topTouchStart:g=p;break;case m.topScroll:g=d;break;case m.topWheel:g=f;break;case m.topCopy:case m.topCut:case m.topPaste:g=i}h(g);var C=g.getPooled(v,n,r);return o.accumulateTwoPhaseDispatches(C),C}};t.exports=E},{"./EventConstants":16,"./EventPluginUtils":20,"./EventPropagators":21,"./SyntheticClipboardEvent":90,"./SyntheticDragEvent":92,"./SyntheticEvent":93,"./SyntheticFocusEvent":94,"./SyntheticKeyboardEvent":96,"./SyntheticMouseEvent":97,"./SyntheticTouchEvent":98,"./SyntheticUIEvent":99,"./SyntheticWheelEvent":100,"./invariant":131,"./keyOf":138}],90:[function(e,t){"use strict";function n(e,t,n){r.call(this,e,t,n)}var r=e("./SyntheticEvent"),o={clipboardData:function(e){return"clipboardData"in e?e.clipboardData:window.clipboardData}};r.augmentClass(n,o),t.exports=n},{"./SyntheticEvent":93}],91:[function(e,t){"use strict";function n(e,t,n){r.call(this,e,t,n)}var r=e("./SyntheticEvent"),o={data:null};r.augmentClass(n,o),t.exports=n},{"./SyntheticEvent":93}],92:[function(e,t){"use strict";function n(e,t,n){r.call(this,e,t,n)}var r=e("./SyntheticMouseEvent"),o={dataTransfer:null};r.augmentClass(n,o),t.exports=n},{"./SyntheticMouseEvent":97}],93:[function(e,t){"use strict";function n(e,t,n){this.dispatchConfig=e,this.dispatchMarker=t,this.nativeEvent=n;var r=this.constructor.Interface;for(var i in r)if(r.hasOwnProperty(i)){var a=r[i];this[i]=a?a(n):n[i]}var s=null!=n.defaultPrevented?n.defaultPrevented:n.returnValue===!1;this.isDefaultPrevented=s?o.thatReturnsTrue:o.thatReturnsFalse,this.isPropagationStopped=o.thatReturnsFalse}var r=e("./PooledClass"),o=e("./emptyFunction"),i=e("./getEventTarget"),a=e("./merge"),s=e("./mergeInto"),u={type:null,target:i,currentTarget:o.thatReturnsNull,eventPhase:null,bubbles:null,cancelable:null,timeStamp:function(e){return e.timeStamp||Date.now()},defaultPrevented:null,isTrusted:null};s(n.prototype,{preventDefault:function(){this.defaultPrevented=!0;var e=this.nativeEvent;e.preventDefault?e.preventDefault():e.returnValue=!1,this.isDefaultPrevented=o.thatReturnsTrue},stopPropagation:function(){var e=this.nativeEvent;e.stopPropagation?e.stopPropagation():e.cancelBubble=!0,this.isPropagationStopped=o.thatReturnsTrue},persist:function(){this.isPersistent=o.thatReturnsTrue},isPersistent:o.thatReturnsFalse,destructor:function(){var e=this.constructor.Interface;for(var t in e)this[t]=null;this.dispatchConfig=null,this.dispatchMarker=null,this.nativeEvent=null}}),n.Interface=u,n.augmentClass=function(e,t){var n=this,o=Object.create(n.prototype);s(o,e.prototype),e.prototype=o,e.prototype.constructor=e,e.Interface=a(n.Interface,t),e.augmentClass=n.augmentClass,r.addPoolingTo(e,r.threeArgumentPooler)},r.addPoolingTo(n,r.threeArgumentPooler),t.exports=n},{"./PooledClass":28,"./emptyFunction":113,"./getEventTarget":122,"./merge":141,"./mergeInto":143}],94:[function(e,t){"use strict";function n(e,t,n){r.call(this,e,t,n)}var r=e("./SyntheticUIEvent"),o={relatedTarget:null};r.augmentClass(n,o),t.exports=n},{"./SyntheticUIEvent":99}],95:[function(e,t){"use strict";function n(e,t,n){r.call(this,e,t,n)}var r=e("./SyntheticEvent"),o={data:null};r.augmentClass(n,o),t.exports=n},{"./SyntheticEvent":93}],96:[function(e,t){"use strict";function n(e,t,n){r.call(this,e,t,n)}var r=e("./SyntheticUIEvent"),o=e("./getEventKey"),i=e("./getEventModifierState"),a={key:o,location:null,ctrlKey:null,shiftKey:null,altKey:null,metaKey:null,repeat:null,locale:null,getModifierState:i,charCode:function(e){return"keypress"===e.type?&q