Merge fx-team to m-c a=merge
authorWes Kocher <wkocher@mozilla.com>
Fri, 16 Jan 2015 16:45:53 -0800
changeset 224343 be3ff0fea299b223120e16f67c59f522b1c61fe8
parent 224312 c9162436444e380ba0df573084bcf8947daf0058 (current diff)
parent 224342 a46d323baa06cc5c5ad64d1f2145c1156feedd39 (diff)
child 224344 369a8f14ccf8c41da54d76992cb205080fccce51
push id54190
push userkwierso@gmail.com
push dateSat, 17 Jan 2015 02:06:29 +0000
treeherdermozilla-inbound@369a8f14ccf8 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone38.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge fx-team to m-c a=merge
browser/components/loop/test/xpcshell/test_loopservice_expiry.js
browser/devtools/shared/test/browser_flame-graph-utils.js
mobile/android/base/resources/drawable-hdpi/ab_stacked_transparent_light_holo.9.png
mobile/android/base/resources/drawable-mdpi/ab_stacked_transparent_light_holo.9.png
mobile/android/base/resources/drawable-xhdpi/ab_stacked_transparent_light_holo.9.png
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1425,17 +1425,19 @@ pref("devtools.timeline.hiddenMarkers", 
   pref("devtools.performance_dev.enabled", true);
 #else
   pref("devtools.performance_dev.enabled", false);
 #endif
 
 pref("devtools.performance.ui.show-timeline-memory", false);
 
 // The default Profiler UI settings
+pref("devtools.profiler.ui.flatten-tree-recursion", true);
 pref("devtools.profiler.ui.show-platform-data", false);
+pref("devtools.profiler.ui.show-idle-blocks", true);
 
 // The default cache UI setting
 pref("devtools.cache.disabled", false);
 
 // Enable the Network Monitor
 pref("devtools.netmonitor.enabled", true);
 
 // The default Network Monitor UI settings
@@ -1670,17 +1672,16 @@ pref("loop.debug.websocket", false);
 pref("loop.debug.sdk", false);
 #ifdef DEBUG
 pref("loop.CSP", "default-src 'self' about: file: chrome: http://localhost:*; img-src 'self' data: http://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net http://localhost:* ws://localhost:*; media-src blob:");
 #else
 pref("loop.CSP", "default-src 'self' about: file: chrome:; img-src 'self' data: http://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net; media-src blob:");
 #endif
 pref("loop.oauth.google.redirect_uri", "urn:ietf:wg:oauth:2.0:oob:auto");
 pref("loop.oauth.google.scope", "https://www.google.com/m8/feeds");
-pref("loop.rooms.enabled", true);
 pref("loop.fxa_oauth.tokendata", "");
 pref("loop.fxa_oauth.profile", "");
 pref("loop.support_url", "https://support.mozilla.org/kb/group-conversations-firefox-hello-webrtc");
 
 // serverURL to be assigned by services team
 pref("services.push.serverURL", "wss://push.services.mozilla.com/");
 
 pref("social.sidebar.unload_timeout_ms", 10000);
--- a/browser/base/content/aboutDialog.js
+++ b/browser/base/content/aboutDialog.js
@@ -485,17 +485,17 @@ appUpdater.prototype =
       return;
 
     if (this.addons.length == 0) {
       // Compatibility updates or new version updates were found for all add-ons
       this.startDownload();
       return;
     }
 
-    this.selectPanel("apply");
+    this.selectPanel("applyBillboard");
   },
 
   /**
    * Starts the download of an update mar.
    */
   startDownload: function() {
     if (!this.update)
       this.update = this.um.activeUpdate;
--- a/browser/base/content/browser-ctrlTab.js
+++ b/browser/base/content/browser-ctrlTab.js
@@ -394,19 +394,19 @@ var ctrlTab = {
       if (self.isOpen)
         self._trackMouseOver = true;
     }, 0, this);
   },
 
   suspendGUI: function ctrlTab_suspendGUI() {
     document.removeEventListener("keyup", this, true);
 
-    Array.forEach(this.previews, function (preview) {
+    for (let preview of this.previews) {
       this.updatePreview(preview, null);
-    }, this);
+    }
   },
 
   onKeyPress: function ctrlTab_onKeyPress(event) {
     var isOpen = this.isOpen;
 
     if (isOpen) {
       event.preventDefault();
       event.stopPropagation();
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -298,16 +298,18 @@ skip-if = os == "mac" || e10s # bug 9670
 [browser_customize_popupNotification.js]
 [browser_datareporting_notification.js]
 run-if = datareporting
 [browser_devedition.js]
 [browser_devices_get_user_media.js]
 skip-if = buildapp == 'mulet' || (os == "linux" && debug) || e10s # linux: bug 976544; e10s: bug 1071623
 [browser_devices_get_user_media_about_urls.js]
 skip-if = e10s # Bug 1071623
+[browser_devices_get_user_media_in_frame.js]
+skip-if = e10s # Bug 1071623
 [browser_discovery.js]
 [browser_double_close_tab.js]
 skip-if = e10s
 [browser_duplicateIDs.js]
 [browser_drag.js]
 skip-if = true # browser_drag.js is disabled, as it needs to be updated for the new behavior from bug 320638.
 [browser_favicon_change.js]
 skip-if = e10s
--- a/browser/base/content/test/general/browser_devices_get_user_media.js
+++ b/browser/base/content/test/general/browser_devices_get_user_media.js
@@ -455,16 +455,60 @@ let gTests = [
     yield checkNotSharing();
 
     // the stream is already closed, but this will do some cleanup anyway
     yield closeStream(true);
   }
 },
 
 {
+  desc: "getUserMedia audio+video: reloading the page removes all gUM UI",
+  run: function checkReloading() {
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
+      info("requesting devices");
+      content.wrappedJSObject.requestDevice(true, true);
+    });
+    expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
+    is(getMediaCaptureState(), "CameraAndMicrophone",
+       "expected camera and microphone to be shared");
+
+    yield checkSharingUI({video: true, audio: true});
+
+    yield promiseNotificationShown(PopupNotifications.getNotification("webRTC-sharingDevices"));
+
+    info("reloading the web page");
+    let deferred = Promise.defer();
+    let browser = gBrowser.selectedBrowser;
+    browser.addEventListener("load", function onload() {
+      browser.removeEventListener("load", onload, true);
+      deferred.resolve();
+    }, true);
+    content.location.reload();
+    yield deferred.promise;
+
+    yield promiseNoPopupNotification("webRTC-sharingDevices");
+    if (gObservedTopics["recording-device-events"] == 2) {
+      todo(false, "Got the 'recording-device-events' notification twice, likely because of bug 962719");
+      --gObservedTopics["recording-device-events"];
+    }
+    expectObserverCalled("recording-device-events");
+    expectObserverCalled("recording-window-ended");
+    expectNoObserverCalled();
+    yield checkNotSharing();
+  }
+},
+
+{
   desc: "getUserMedia prompt: Always/Never Share",
   run: function checkRememberCheckbox() {
     let elt = id => document.getElementById(id);
 
     function checkPerm(aRequestAudio, aRequestVideo, aAllowAudio, aAllowVideo,
                        aExpectedAudioPerm, aExpectedVideoPerm, aNever) {
       yield promisePopupNotificationShown("webRTC-shareDevices", () => {
         content.wrappedJSObject.requestDevice(aRequestAudio, aRequestVideo);
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_devices_get_user_media_in_frame.js
@@ -0,0 +1,477 @@
+/* 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/. */
+
+const kObservedTopics = [
+  "getUserMedia:response:allow",
+  "getUserMedia:revoke",
+  "getUserMedia:response:deny",
+  "getUserMedia:request",
+  "recording-device-events",
+  "recording-window-ended"
+];
+
+const PREF_PERMISSION_FAKE = "media.navigator.permission.fake";
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyServiceGetter(this, "MediaManagerService",
+                                   "@mozilla.org/mediaManagerService;1",
+                                   "nsIMediaManagerService");
+
+var gObservedTopics = {};
+function observer(aSubject, aTopic, aData) {
+  if (!(aTopic in gObservedTopics))
+    gObservedTopics[aTopic] = 1;
+  else
+    ++gObservedTopics[aTopic];
+}
+
+function promiseObserverCalled(aTopic, aAction) {
+  let deferred = Promise.defer();
+
+  Services.obs.addObserver(function observer() {
+    ok(true, "got " + aTopic + " notification");
+    Services.obs.removeObserver(observer, aTopic);
+
+    if (kObservedTopics.indexOf(aTopic) != -1) {
+      if (!(aTopic in gObservedTopics))
+        gObservedTopics[aTopic] = -1;
+      else
+        --gObservedTopics[aTopic];
+    }
+
+    deferred.resolve();
+  }, aTopic, false);
+
+  if (aAction)
+    aAction();
+
+  return deferred.promise;
+}
+
+function expectObserverCalled(aTopic) {
+  is(gObservedTopics[aTopic], 1, "expected notification " + aTopic);
+  if (aTopic in gObservedTopics)
+    --gObservedTopics[aTopic];
+}
+
+function expectNoObserverCalled() {
+  for (let topic in gObservedTopics) {
+    if (gObservedTopics[topic])
+      is(gObservedTopics[topic], 0, topic + " notification unexpected");
+  }
+  gObservedTopics = {};
+}
+
+function promiseMessage(aMessage, aAction) {
+  let deferred = Promise.defer();
+
+  content.addEventListener("message", function messageListener(event) {
+    content.removeEventListener("message", messageListener);
+    is(event.data, aMessage, "received " + aMessage);
+    if (event.data == aMessage)
+      deferred.resolve();
+    else
+      deferred.reject();
+  });
+
+  if (aAction)
+    aAction();
+
+  return deferred.promise;
+}
+
+function promisePopupNotificationShown(aName, aAction) {
+  let deferred = Promise.defer();
+
+  PopupNotifications.panel.addEventListener("popupshown", function popupNotifShown() {
+    PopupNotifications.panel.removeEventListener("popupshown", popupNotifShown);
+
+    ok(!!PopupNotifications.getNotification(aName), aName + " notification shown");
+    ok(PopupNotifications.isPanelOpen, "notification panel open");
+    ok(!!PopupNotifications.panel.firstChild, "notification panel populated");
+
+    deferred.resolve();
+  });
+
+  if (aAction)
+    aAction();
+
+  return deferred.promise;
+}
+
+function promisePopupNotification(aName) {
+  let deferred = Promise.defer();
+
+  waitForCondition(() => PopupNotifications.getNotification(aName),
+                   () => {
+    ok(!!PopupNotifications.getNotification(aName),
+       aName + " notification appeared");
+
+    deferred.resolve();
+  }, "timeout waiting for popup notification " + aName);
+
+  return deferred.promise;
+}
+
+function promiseNoPopupNotification(aName) {
+  let deferred = Promise.defer();
+
+  waitForCondition(() => !PopupNotifications.getNotification(aName),
+                   () => {
+    ok(!PopupNotifications.getNotification(aName),
+       aName + " notification removed");
+    deferred.resolve();
+  }, "timeout waiting for popup notification " + aName + " to disappear");
+
+  return deferred.promise;
+}
+
+const kActionAlways = 1;
+const kActionDeny = 2;
+const kActionNever = 3;
+
+function activateSecondaryAction(aAction) {
+  let notification = PopupNotifications.panel.firstChild;
+  notification.button.focus();
+  let popup = notification.menupopup;
+  popup.addEventListener("popupshown", function () {
+    popup.removeEventListener("popupshown", arguments.callee, false);
+
+    // Press 'down' as many time as needed to select the requested action.
+    while (aAction--)
+      EventUtils.synthesizeKey("VK_DOWN", {});
+
+    // Activate
+    EventUtils.synthesizeKey("VK_RETURN", {});
+  }, false);
+
+  // One down event to open the popup
+  EventUtils.synthesizeKey("VK_DOWN",
+                           { altKey: !navigator.platform.contains("Mac") });
+}
+
+registerCleanupFunction(function() {
+  gBrowser.removeCurrentTab();
+  kObservedTopics.forEach(topic => {
+    Services.obs.removeObserver(observer, topic);
+  });
+  Services.prefs.clearUserPref(PREF_PERMISSION_FAKE);
+});
+
+function getMediaCaptureState() {
+  let hasVideo = {};
+  let hasAudio = {};
+  MediaManagerService.mediaCaptureWindowState(content, hasVideo, hasAudio);
+  if (hasVideo.value && hasAudio.value)
+    return "CameraAndMicrophone";
+  if (hasVideo.value)
+    return "Camera";
+  if (hasAudio.value)
+    return "Microphone";
+  return "none";
+}
+
+function* closeStream(aGlobal, aAlreadyClosed) {
+  expectNoObserverCalled();
+
+  info("closing the stream");
+  aGlobal.closeStream();
+
+  if (!aAlreadyClosed)
+    yield promiseObserverCalled("recording-device-events");
+
+  yield promiseNoPopupNotification("webRTC-sharingDevices");
+  if (!aAlreadyClosed)
+    expectObserverCalled("recording-window-ended");
+
+  yield* assertWebRTCIndicatorStatus(null);
+}
+
+function checkDeviceSelectors(aAudio, aVideo) {
+  let micSelector = document.getElementById("webRTC-selectMicrophone");
+  if (aAudio)
+    ok(!micSelector.hidden, "microphone selector visible");
+  else
+    ok(micSelector.hidden, "microphone selector hidden");
+
+  let cameraSelector = document.getElementById("webRTC-selectCamera");
+  if (aVideo)
+    ok(!cameraSelector.hidden, "camera selector visible");
+  else
+    ok(cameraSelector.hidden, "camera selector hidden");
+}
+
+function* checkSharingUI(aExpected) {
+  yield promisePopupNotification("webRTC-sharingDevices");
+
+  yield* assertWebRTCIndicatorStatus(aExpected);
+}
+
+function* checkNotSharing() {
+  is(getMediaCaptureState(), "none", "expected nothing to be shared");
+
+  ok(!PopupNotifications.getNotification("webRTC-sharingDevices"),
+     "no webRTC-sharingDevices popup notification");
+
+  yield* assertWebRTCIndicatorStatus(null);
+}
+
+function getFrameGlobal(aFrameId) {
+  return content.wrappedJSObject.document.getElementById(aFrameId).contentWindow;
+}
+
+const permissionError = "error: PermissionDeniedError: The user did not grant permission for the operation.";
+
+let gTests = [
+
+{
+  desc: "getUserMedia audio+video",
+  run: function checkAudioVideo() {
+    let global = getFrameGlobal("frame1");
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
+      info("requesting devices");
+      global.requestDevice(true, true);
+    });
+    expectObserverCalled("getUserMedia:request");
+
+    is(PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+       "webRTC-shareDevices-notification-icon", "anchored to device icon");
+    checkDeviceSelectors(true, true);
+    is(PopupNotifications.panel.firstChild.getAttribute("popupid"),
+       "webRTC-shareDevices", "panel using devices icon");
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
+    is(getMediaCaptureState(), "CameraAndMicrophone",
+       "expected camera and microphone to be shared");
+
+    yield checkSharingUI({audio: true, video: true});
+    yield closeStream(global);
+  }
+},
+
+{
+  desc: "getUserMedia audio+video: stop sharing",
+  run: function checkStopSharing() {
+    let global = getFrameGlobal("frame1");
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
+      info("requesting devices");
+      global.requestDevice(true, true);
+    });
+    expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
+    is(getMediaCaptureState(), "CameraAndMicrophone",
+       "expected camera and microphone to be shared");
+
+    yield checkSharingUI({video: true, audio: true});
+
+    yield promiseNotificationShown(PopupNotifications.getNotification("webRTC-sharingDevices"));
+    activateSecondaryAction(kActionDeny);
+
+    yield promiseObserverCalled("recording-device-events");
+    expectObserverCalled("getUserMedia:revoke");
+
+    yield promiseNoPopupNotification("webRTC-sharingDevices");
+    expectObserverCalled("recording-window-ended");
+
+    if (gObservedTopics["recording-device-events"] == 1) {
+      todo(false, "Got the 'recording-device-events' notification twice, likely because of bug 962719");
+      gObservedTopics["recording-device-events"] = 0;
+    }
+
+    expectNoObserverCalled();
+    yield checkNotSharing();
+
+    // the stream is already closed, but this will do some cleanup anyway
+    yield closeStream(global, true);
+  }
+},
+
+{
+  desc: "getUserMedia audio+video: reloading the frame removes all sharing UI",
+  run: function checkReloading() {
+    let global = getFrameGlobal("frame1");
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
+      info("requesting devices");
+      global.requestDevice(true, true);
+    });
+    expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
+    is(getMediaCaptureState(), "CameraAndMicrophone",
+       "expected camera and microphone to be shared");
+
+    yield checkSharingUI({video: true, audio: true});
+
+    info("reloading the frame");
+    let deferred = Promise.defer();
+    let browser = gBrowser.selectedBrowser;
+    browser.addEventListener("load", function onload() {
+      browser.removeEventListener("load", onload, true);
+      deferred.resolve();
+    }, true);
+    global.location.reload();
+    yield deferred.promise;
+
+    yield promiseNoPopupNotification("webRTC-sharingDevices");
+    if (gObservedTopics["recording-device-events"] == 2) {
+      todo(false, "Got the 'recording-device-events' notification twice, likely because of bug 962719");
+      --gObservedTopics["recording-device-events"];
+    }
+    expectObserverCalled("recording-device-events");
+    expectObserverCalled("recording-window-ended");
+    expectNoObserverCalled();
+    yield checkNotSharing();
+  }
+},
+
+{
+  desc: "getUserMedia audio+video: reloading the frame removes prompts",
+  run: function checkReloadingRemovesPrompts() {
+    let global = getFrameGlobal("frame1");
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
+      info("requesting devices");
+      global.requestDevice(true, true);
+    });
+    expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    info("reloading the frame");
+    let deferred = Promise.defer();
+    let browser = gBrowser.selectedBrowser;
+    browser.addEventListener("load", function onload() {
+      browser.removeEventListener("load", onload, true);
+      deferred.resolve();
+    }, true);
+    global.location.reload();
+    yield deferred.promise;
+
+    yield promiseNoPopupNotification("webRTC-shareDevices");
+
+    expectObserverCalled("recording-window-ended");
+    expectNoObserverCalled();
+    yield checkNotSharing();
+  }
+},
+
+{
+  desc: "getUserMedia audio+video: reloading a frame updates the sharing UI",
+  run: function checkUpdateWhenReloading() {
+    // We'll share only the mic in the first frame, then share both in the
+    // second frame, then reload the second frame. After each step, we'll check
+    // the UI is in the correct state.
+    let g1 = getFrameGlobal("frame1"), g2 = getFrameGlobal("frame2");
+
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
+      info("requesting microphone in the first frame");
+      g1.requestDevice(true, false);
+    });
+    expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, false);
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
+    is(getMediaCaptureState(), "Microphone", "microphone to be shared");
+
+    yield checkSharingUI({video: false, audio: true});
+    expectNoObserverCalled();
+
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
+      info("requesting both devices in the second frame");
+      g2.requestDevice(true, true);
+    });
+    expectObserverCalled("getUserMedia:request");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
+    is(getMediaCaptureState(), "CameraAndMicrophone",
+       "expected camera and microphone to be shared");
+
+    yield checkSharingUI({video: true, audio: true});
+    expectNoObserverCalled();
+
+    info("reloading the second frame");
+    let deferred = Promise.defer();
+    let browser = gBrowser.selectedBrowser;
+    browser.addEventListener("load", function onload() {
+      browser.removeEventListener("load", onload, true);
+      deferred.resolve();
+    }, true);
+    g2.location.reload();
+    yield deferred.promise;
+
+    yield checkSharingUI({video: false, audio: true});
+    expectObserverCalled("recording-window-ended");
+    if (gObservedTopics["recording-device-events"] == 2) {
+      todo(false, "Got the 'recording-device-events' notification twice, likely because of bug 962719");
+      --gObservedTopics["recording-device-events"];
+    }
+    expectObserverCalled("recording-device-events");
+    expectNoObserverCalled();
+
+    yield closeStream(g1);
+    yield promiseNoPopupNotification("webRTC-sharingDevices");
+    expectNoObserverCalled();
+    yield checkNotSharing();
+  }
+}
+
+];
+
+function test() {
+  waitForExplicitFinish();
+
+  let tab = gBrowser.addTab();
+  gBrowser.selectedTab = tab;
+  tab.linkedBrowser.addEventListener("load", function onload() {
+    tab.linkedBrowser.removeEventListener("load", onload, true);
+
+    kObservedTopics.forEach(topic => {
+      Services.obs.addObserver(observer, topic, false);
+    });
+    Services.prefs.setBoolPref(PREF_PERMISSION_FAKE, true);
+
+    is(PopupNotifications._currentNotifications.length, 0,
+       "should start the test without any prior popup notification");
+
+    Task.spawn(function () {
+      for (let test of gTests) {
+        info(test.desc);
+        yield test.run();
+
+        // Cleanup before the next test
+        expectNoObserverCalled();
+      }
+    }).then(finish, ex => {
+     ok(false, "Unexpected Exception: " + ex);
+     finish();
+    });
+  }, true);
+  let rootDir = getRootDirectory(gTestPath);
+  rootDir = rootDir.replace("chrome://mochitests/content/",
+                            "https://example.com/");
+  let url = rootDir + "get_user_media.html";
+  content.location = 'data:text/html,<iframe id="frame1" src="' + url + '"></iframe><iframe id="frame2" src="' + url + '"></iframe>'
+}
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -329,24 +329,26 @@
             try {
               addToUrlbarHistory(url);
             } catch (ex) {
               // Things may go wrong when adding url to session history,
               // but don't let that interfere with the loading of the url.
               Cu.reportError(ex);
             }
 
-            function loadCurrent() {
+            let loadCurrent = () => {
               openUILinkIn(url, "current", {
                 allowThirdPartyFixup: true,
                 disallowInheritPrincipal: !mayInheritPrincipal,
                 allowPinnedTabHostChange: true,
                 postData: postData
               });
-            }
+              // Ensure the start of the URL is visible for UX reasons:
+              this.selectionStart = this.selectionEnd = 0;
+            };
 
             // Focus the content area before triggering loads, since if the load
             // occurs in a new tab, we want focus to be restored to the content
             // area when the current tab is re-selected.
             gBrowser.selectedBrowser.focus();
 
             let isMouseEvent = aTriggeringEvent instanceof MouseEvent;
             let altEnter = !isMouseEvent && aTriggeringEvent && aTriggeringEvent.altKey;
@@ -1144,17 +1146,18 @@
 
         let currentEngineName = Services.search.currentEngine.name;
         let engines = Services.search.getVisibleEngines()
                               .filter(e => e.name != currentEngineName &&
                                            hiddenList.indexOf(e.name) == -1);
 
         let header = document.getAnonymousElementByAttribute(this, "anonid",
                                                              "search-panel-one-offs-header")
-        header.collapsed = list.collapsed = !engines.length;
+        // header is a xul:deck so collapsed doesn't work on it, see bug 589569.
+        header.hidden = list.collapsed = !engines.length;
 
         // 49px is the min-width of each search engine button,
         // adapt this const when changing the css.
         // It's actually 48px + 1px of right border.
         const ENGINE_WIDTH = 49;
         let panel = document.getElementById("PopupSearchAutoComplete");
         // The panel width only spans to the textbox size, but we also want it
         // to include the magnifier icon's width.
--- a/browser/components/downloads/test/browser/browser.ini
+++ b/browser/components/downloads/test/browser/browser.ini
@@ -3,8 +3,12 @@ support-files = head.js
 
 [browser_basic_functionality.js]
 skip-if = buildapp == "mulet" || e10s
 [browser_first_download_panel.js]
 skip-if = os == "linux" # Bug 949434
 [browser_overflow_anchor.js]
 skip-if = os == "linux" # Bug 952422
 [browser_confirm_unblock_download.js]
+
+[browser_iframe_gone_mid_download.js]
+skip-if = e10s
+
new file mode 100644
--- /dev/null
+++ b/browser/components/downloads/test/browser/browser_iframe_gone_mid_download.js
@@ -0,0 +1,62 @@
+const SAVE_PER_SITE_PREF = "browser.download.lastDir.savePerSite";
+
+function test_deleted_iframe(perSitePref, windowOptions={}) {
+  return function*() {
+    Services.prefs.setBoolPref(SAVE_PER_SITE_PREF, perSitePref);
+    let {DownloadLastDir} = Cu.import("resource://gre/modules/DownloadLastDir.jsm", {});
+
+    let win = yield promiseOpenAndLoadWindow(windowOptions);
+    let tab = win.gBrowser.addTab();
+    yield promiseTabLoadEvent(tab, "about:mozilla");
+
+    let doc = tab.linkedBrowser.contentDocument;
+    let iframe = doc.createElement("iframe");
+    doc.body.appendChild(iframe);
+
+    ok(iframe.contentWindow, "iframe should have a window");
+    let gDownloadLastDir = new DownloadLastDir(iframe.contentWindow);
+    let cw = iframe.contentWindow;
+    let promiseIframeWindowGone = new Promise((resolve, reject) => {
+      Services.obs.addObserver(function obs(subject, topic) {
+        if (subject == cw) {
+          Services.obs.removeObserver(obs, topic);
+          resolve();
+        }
+      }, "dom-window-destroyed", false);
+    });
+    iframe.remove();
+    yield promiseIframeWindowGone;
+    cw = null;
+    ok(!iframe.contentWindow, "Managed to destroy iframe");
+
+    let someDir = "blah";
+    try {
+      someDir = yield new Promise((resolve, reject) => {
+        gDownloadLastDir.getFileAsync("http://www.mozilla.org/", function(dir) {
+          resolve(dir);
+        });
+      });
+    } catch (ex) {
+      ok(false, "Got an exception trying to get the directory where things should be saved.");
+      Cu.reportError(ex);
+    }
+    // NB: someDir can legitimately be null here when set, hence the 'blah' workaround:
+    isnot(someDir, "blah", "Should get a file even after the window was destroyed.");
+
+    try {
+      gDownloadLastDir.setFile("http://www.mozilla.org/", null);
+    } catch (ex) {
+      ok(false, "Got an exception trying to set the directory where things should be saved.");
+      Cu.reportError(ex);
+    }
+
+    yield promiseWindowClosed(win);
+    Services.prefs.clearUserPref(SAVE_PER_SITE_PREF);
+  };
+}
+
+add_task(test_deleted_iframe(false));
+add_task(test_deleted_iframe(false));
+add_task(test_deleted_iframe(true, {private: true}));
+add_task(test_deleted_iframe(true, {private: true}));
+
--- a/browser/components/downloads/test/browser/head.js
+++ b/browser/components/downloads/test/browser/head.js
@@ -26,16 +26,86 @@ let gTestTargetFile = FileUtils.getFile(
 gTestTargetFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
 registerCleanupFunction(function () {
   gTestTargetFile.remove(false);
 });
 
 ////////////////////////////////////////////////////////////////////////////////
 //// Asynchronous support subroutines
 
+function promiseOpenAndLoadWindow(aOptions)
+{
+  return new Promise((resolve, reject) => {
+    let win = OpenBrowserWindow(aOptions);
+    win.addEventListener("load", function onLoad() {
+      win.removeEventListener("load", onLoad);
+      resolve(win);
+    });
+  });
+}
+
+/**
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ *        The tab to load into.
+ * @param [optional] url
+ *        The url to load, or the current url.
+ * @param [optional] event
+ *        The load event type to wait for.  Defaults to "load".
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+function promiseTabLoadEvent(tab, url, eventType="load")
+{
+  let deferred = Promise.defer();
+  info("Wait tab event: " + eventType);
+
+  function handle(event) {
+    if (event.originalTarget != tab.linkedBrowser.contentDocument ||
+        event.target.location.href == "about:blank" ||
+        (url && event.target.location.href != url)) {
+      info("Skipping spurious '" + eventType + "'' event" +
+           " for " + event.target.location.href);
+      return;
+    }
+    // Remove reference to tab from the cleanup function:
+    realCleanup = () => {};
+    tab.linkedBrowser.removeEventListener(eventType, handle, true);
+    info("Tab event received: " + eventType);
+    deferred.resolve(event);
+  }
+
+  // Juggle a bit to avoid leaks:
+  let realCleanup = () => tab.linkedBrowser.removeEventListener(eventType, handle, true);
+  registerCleanupFunction(() => realCleanup());
+
+  tab.linkedBrowser.addEventListener(eventType, handle, true, true);
+  if (url)
+    tab.linkedBrowser.loadURI(url);
+  return deferred.promise;
+}
+
+function promiseWindowClosed(win)
+{
+  let promise = new Promise((resolve, reject) => {
+    Services.obs.addObserver(function obs(subject, topic) {
+      if (subject == win) {
+        Services.obs.removeObserver(obs, topic);
+        resolve();
+      }
+    }, "domwindowclosed", false);
+  });
+  win.close();
+  return promise;
+}
+
+
 function promiseFocus()
 {
   let deferred = Promise.defer();
   waitForFocus(deferred.resolve);
   return deferred.promise;
 }
 
 function promisePanelOpened()
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -412,36 +412,16 @@ function injectLoopAPI(targetWindow) {
           callback(null, chosenButton == 0);
         } catch (ex) {
           callback(cloneValueInto(ex, targetWindow));
         }
       }
     },
 
     /**
-     * Used to note a call url expiry time. If the time is later than the current
-     * latest expiry time, then the stored expiry time is increased. For times
-     * sooner, this function is a no-op; this ensures we always have the latest
-     * expiry time for a url.
-     *
-     * This is used to determine whether or not we should be registering with the
-     * push server on start.
-     *
-     * @param {Integer} expiryTimeSeconds The seconds since epoch of the expiry time
-     *                                    of the url.
-     */
-    noteCallUrlExpiry: {
-      enumerable: true,
-      writable: true,
-      value: function(expiryTimeSeconds) {
-        MozLoopService.noteCallUrlExpiry(expiryTimeSeconds);
-      }
-    },
-
-    /**
      * Set any preference under "loop."
      *
      * @param {String} prefName The name of the pref without the preceding "loop."
      * @param {*} value The value to set.
      * @param {Enum} prefType Type of preference, defined at Ci.nsIPrefBranch. Optional.
      *
      * Any errors thrown by the Mozilla pref API are logged to the console
      * and cause false to be returned.
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -1239,32 +1239,16 @@ this.MozLoopService = {
   /**
    * @see MozLoopServiceInternal.promiseRegisteredWithServers
    */
   promiseRegisteredWithServers: function(sessionType = LOOP_SESSION_TYPE.GUEST) {
     return MozLoopServiceInternal.promiseRegisteredWithServers(sessionType);
   },
 
   /**
-   * Used to note a call url expiry time. If the time is later than the current
-   * latest expiry time, then the stored expiry time is increased. For times
-   * sooner, this function is a no-op; this ensures we always have the latest
-   * expiry time for a url.
-   *
-   * This is used to determine whether or not we should be registering with the
-   * push server on start.
-   *
-   * @param {Integer} expiryTimeSeconds The seconds since epoch of the expiry time
-   *                                    of the url.
-   */
-  noteCallUrlExpiry: function(expiryTimeSeconds) {
-    MozLoopServiceInternal.expiryTimeSeconds = expiryTimeSeconds;
-  },
-
-  /**
    * Returns the strings for the specified element. Designed for use with l10n.js.
    *
    * @param {key} The element id to get strings for.
    * @return {String} A JSON string containing the localized attribute/value pairs
    *                  for the element.
    */
   getStrings: function(key) {
     var stringData = MozLoopServiceInternal.localizedStrings;
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -4,22 +4,16 @@
 
 /* jshint esnext:true */
 /* global loop:true, hawk, deriveHawkCredentials */
 
 var loop = loop || {};
 loop.Client = (function($) {
   "use strict";
 
-  // The expected properties to be returned from the POST /call-url/ request.
-  var expectedCallUrlProperties = ["callUrl", "expiresAt"];
-
-  // The expected properties to be returned from the GET /calls request.
-  var expectedCallProperties = ["calls"];
-
   // THe expected properties to be returned from the POST /calls request.
   var expectedPostCallProperties = [
     "apiKey", "callId", "progressURL",
     "sessionId", "sessionToken", "websocketToken"
   ];
 
   /**
    * Loop server client.
@@ -77,66 +71,16 @@ loop.Client = (function($) {
      */
     _failureHandler: function(cb, error) {
       var message = "HTTP " + error.code + " " + error.error + "; " + error.message;
       console.error(message);
       cb(error);
     },
 
     /**
-     * Requests a call URL from the Loop server. It will note the
-     * 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 request, 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)
-     */
-    requestCallUrl: function(nickname, cb) {
-      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);
-
-            // This throws if the data is invalid, in which case only the failure
-            // telemetry will be recorded.
-            var returnData = this._validate(urlData, expectedCallUrlProperties);
-
-            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 {mozLoop.LOOP_SESSION_TYPE} sessionType The type of session which
      *                                                the url belongs to.
      * @param {function} cb Callback function used for handling an error
      *                      response. XXX The incoming call panel does not
      *                      exist after the block button is clicked therefore
@@ -198,26 +142,12 @@ loop.Client = (function($) {
             cb(null, outgoingCallData);
           } catch (err) {
             console.log("Error requesting call info", err);
             cb(err);
           }
         }.bind(this)
       );
     },
-
-    /**
-     * Adds a value to a telemetry histogram, ignoring errors.
-     *
-     * @param  {string}  histogramId Name of the telemetry histogram to update.
-     * @param  {integer} value       Value to add to the histogram.
-     */
-    _telemetryAdd: function(histogramId, value) {
-      try {
-        this.mozLoop.telemetryAdd(histogramId, value);
-      } catch (err) {
-        console.error("Error recording telemetry", err);
-      }
-    },
   };
 
   return Client;
 })(jQuery);
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -258,16 +258,21 @@ loop.contacts = (function(_, mozL10n) {
   });
 
   const ContactsList = React.createClass({displayName: "ContactsList",
     mixins: [
       React.addons.LinkedStateMixin,
       loop.shared.mixins.WindowCloseMixin
     ],
 
+    propTypes: {
+      notifications: React.PropTypes.instanceOf(
+        loop.shared.models.NotificationCollection).isRequired
+    },
+
     /**
      * Contacts collection object
      */
     contacts: null,
 
     /**
      * User profile
      */
@@ -384,20 +389,24 @@ loop.contacts = (function(_, mozL10n) {
     },
 
     handleImportButtonClick: function() {
       this.setState({ importBusy: true });
       navigator.mozLoop.startImport({
         service: "google"
       }, (err, stats) => {
         this.setState({ importBusy: false });
-        // TODO: bug 1076764 - proper error and success reporting.
         if (err) {
-          throw err;
+          console.error("Contact import error", err);
+          this.props.notifications.errorL10n("import_contacts_failure_message");
+          return;
         }
+        this.props.notifications.successL10n("import_contacts_success_message", {
+          total: stats.total
+        });
       });
     },
 
     handleAddContactButtonClick: function() {
       this.props.startForm("contacts_add");
     },
 
     handleContactAction: function(contact, actionName) {
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -258,16 +258,21 @@ loop.contacts = (function(_, mozL10n) {
   });
 
   const ContactsList = React.createClass({
     mixins: [
       React.addons.LinkedStateMixin,
       loop.shared.mixins.WindowCloseMixin
     ],
 
+    propTypes: {
+      notifications: React.PropTypes.instanceOf(
+        loop.shared.models.NotificationCollection).isRequired
+    },
+
     /**
      * Contacts collection object
      */
     contacts: null,
 
     /**
      * User profile
      */
@@ -384,20 +389,24 @@ loop.contacts = (function(_, mozL10n) {
     },
 
     handleImportButtonClick: function() {
       this.setState({ importBusy: true });
       navigator.mozLoop.startImport({
         service: "google"
       }, (err, stats) => {
         this.setState({ importBusy: false });
-        // TODO: bug 1076764 - proper error and success reporting.
         if (err) {
-          throw err;
+          console.error("Contact import error", err);
+          this.props.notifications.errorL10n("import_contacts_failure_message");
+          return;
         }
+        this.props.notifications.successL10n("import_contacts_success_message", {
+          total: stats.total
+        });
       });
     },
 
     handleAddContactButtonClick: function() {
       this.props.startForm("contacts_add");
     },
 
     handleContactAction: function(contact, actionName) {
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -830,99 +830,46 @@ loop.conversationViews = (function(mozL1
             )
           )
         )
       );
     }
   });
 
   var OngoingConversationView = React.createClass({displayName: "OngoingConversationView",
+    mixins: [
+      sharedMixins.MediaSetupMixin
+    ],
+
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       video: React.PropTypes.object,
       audio: React.PropTypes.object
     },
 
     getDefaultProps: function() {
       return {
         video: {enabled: true, visible: true},
         audio: {enabled: true, visible: true}
       };
     },
 
     componentDidMount: function() {
-      /**
-       * OT inserts inline styles into the markup. Using a listener for
-       * resize events helps us trigger a full width/height on the element
-       * so that they update to the correct dimensions.
-       * XXX: this should be factored as a mixin.
-       */
-      window.addEventListener('orientationchange', this.updateVideoContainer);
-      window.addEventListener('resize', this.updateVideoContainer);
-
       // The SDK needs to know about the configuration and the elements to use
       // for display. So the best way seems to pass the information here - ideally
       // the sdk wouldn't need to know this, but we can't change that.
       this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
-        publisherConfig: this._getPublisherConfig(),
+        publisherConfig: this.getDefaultPublisherConfig({
+          publishVideo: this.props.video.enabled
+        }),
         getLocalElementFunc: this._getElement.bind(this, ".local"),
         getRemoteElementFunc: this._getElement.bind(this, ".remote")
       }));
     },
 
-    componentWillUnmount: function() {
-      window.removeEventListener('orientationchange', this.updateVideoContainer);
-      window.removeEventListener('resize', this.updateVideoContainer);
-    },
-
-    /**
-     * Returns either the required DOMNode
-     *
-     * @param {String} className The name of the class to get the element for.
-     */
-    _getElement: function(className) {
-      return this.getDOMNode().querySelector(className);
-    },
-
-    /**
-     * Returns the required configuration for publishing video on the sdk.
-     */
-    _getPublisherConfig: function() {
-      // height set to 100%" to fix video layout on Google Chrome
-      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
-      return {
-        insertMode: "append",
-        width: "100%",
-        height: "100%",
-        publishVideo: this.props.video.enabled,
-        style: {
-          audioLevelDisplayMode: "off",
-          bugDisplayMode: "off",
-          buttonDisplayMode: "off",
-          nameDisplayMode: "off",
-          videoDisabledDisplayMode: "off"
-        }
-      };
-    },
-
-    /**
-     * Used to update the video container whenever the orientation or size of the
-     * display area changes.
-     */
-    updateVideoContainer: function() {
-      var localStreamParent = this._getElement('.local .OT_publisher');
-      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
-      if (localStreamParent) {
-        localStreamParent.style.width = "100%";
-      }
-      if (remoteStreamParent) {
-        remoteStreamParent.style.height = "100%";
-      }
-    },
-
     /**
      * Hangs up the call.
      */
     hangup: function() {
       this.props.dispatcher.dispatch(
         new sharedActions.HangupCall());
     },
 
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -830,99 +830,46 @@ loop.conversationViews = (function(mozL1
             </button>
           </div>
         </div>
       );
     }
   });
 
   var OngoingConversationView = React.createClass({
+    mixins: [
+      sharedMixins.MediaSetupMixin
+    ],
+
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       video: React.PropTypes.object,
       audio: React.PropTypes.object
     },
 
     getDefaultProps: function() {
       return {
         video: {enabled: true, visible: true},
         audio: {enabled: true, visible: true}
       };
     },
 
     componentDidMount: function() {
-      /**
-       * OT inserts inline styles into the markup. Using a listener for
-       * resize events helps us trigger a full width/height on the element
-       * so that they update to the correct dimensions.
-       * XXX: this should be factored as a mixin.
-       */
-      window.addEventListener('orientationchange', this.updateVideoContainer);
-      window.addEventListener('resize', this.updateVideoContainer);
-
       // The SDK needs to know about the configuration and the elements to use
       // for display. So the best way seems to pass the information here - ideally
       // the sdk wouldn't need to know this, but we can't change that.
       this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
-        publisherConfig: this._getPublisherConfig(),
+        publisherConfig: this.getDefaultPublisherConfig({
+          publishVideo: this.props.video.enabled
+        }),
         getLocalElementFunc: this._getElement.bind(this, ".local"),
         getRemoteElementFunc: this._getElement.bind(this, ".remote")
       }));
     },
 
-    componentWillUnmount: function() {
-      window.removeEventListener('orientationchange', this.updateVideoContainer);
-      window.removeEventListener('resize', this.updateVideoContainer);
-    },
-
-    /**
-     * Returns either the required DOMNode
-     *
-     * @param {String} className The name of the class to get the element for.
-     */
-    _getElement: function(className) {
-      return this.getDOMNode().querySelector(className);
-    },
-
-    /**
-     * Returns the required configuration for publishing video on the sdk.
-     */
-    _getPublisherConfig: function() {
-      // height set to 100%" to fix video layout on Google Chrome
-      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
-      return {
-        insertMode: "append",
-        width: "100%",
-        height: "100%",
-        publishVideo: this.props.video.enabled,
-        style: {
-          audioLevelDisplayMode: "off",
-          bugDisplayMode: "off",
-          buttonDisplayMode: "off",
-          nameDisplayMode: "off",
-          videoDisabledDisplayMode: "off"
-        }
-      };
-    },
-
-    /**
-     * Used to update the video container whenever the orientation or size of the
-     * display area changes.
-     */
-    updateVideoContainer: function() {
-      var localStreamParent = this._getElement('.local .OT_publisher');
-      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
-      if (localStreamParent) {
-        localStreamParent.style.width = "100%";
-      }
-      if (remoteStreamParent) {
-        remoteStreamParent.style.height = "100%";
-      }
-    },
-
     /**
      * Hangs up the call.
      */
     hangup: function() {
       this.props.dispatcher.dispatch(
         new sharedActions.HangupCall());
     },
 
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -34,19 +34,17 @@ loop.panel = (function(_, mozL10n) {
       };
     },
 
     getInitialState: function() {
       // XXX Work around props.selectedTab being undefined initially.
       // When we don't need to rely on the pref, this can move back to
       // getDefaultProps (bug 1100258).
       return {
-        selectedTab: this.props.selectedTab ||
-          (navigator.mozLoop.getLoopPref("rooms.enabled") ?
-            "rooms" : "call")
+        selectedTab: this.props.selectedTab || "rooms"
       };
     },
 
     handleSelectTab: function(event) {
       var tabName = event.target.dataset.tabName;
       this.setState({selectedTab: tabName});
     },
 
@@ -354,167 +352,16 @@ loop.panel = (function(_, mozL10n) {
                                    icon: "help"})
           )
         )
       );
     }
   });
 
   /**
-   * Call url result view.
-   */
-  var CallUrlResult = React.createClass({displayName: "CallUrlResult",
-    mixins: [sharedMixins.DocumentVisibilityMixin],
-
-    propTypes: {
-      callUrl:        React.PropTypes.string,
-      callUrlExpiry:  React.PropTypes.number,
-      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();
-    },
-
-    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});
-      // XXX This is an empty string as a conversation identifier. Bug 1015938 implements
-      // a user-set string.
-      this.props.client.requestCallUrl("",
-                                       this._onCallUrlReceived);
-    },
-
-    _onCallUrlReceived: function(err, callUrlData) {
-      if (err) {
-        if (err.code != 401) {
-          // 401 errors are already handled in hawkRequest and show an error
-          // message about the session.
-          this.props.notifications.errorL10n("unable_retrieve_url");
-        }
-        this.setState(this.getInitialState());
-      } else {
-        try {
-          var callUrl = new window.URL(callUrlData.callUrl);
-          // XXX the current server vers does not implement the callToken field
-          // but it exists in the API. This workaround should be removed in the future
-          var token = callUrlData.callToken ||
-                      callUrl.pathname.split('/').pop();
-
-          // Now that a new URL is available, indicate it has not been shared.
-          this.linkExfiltrated = false;
-
-          this.setState({pending: false, copied: false,
-                         callUrl: callUrl.href,
-                         callUrlExpiry: callUrlData.expiresAt});
-        } catch(e) {
-          console.log(e);
-          this.props.notifications.errorL10n("unable_retrieve_url");
-          this.setState(this.getInitialState());
-        }
-      }
-    },
-
-    handleEmailButtonClick: function(event) {
-      this.handleLinkExfiltration(event);
-
-      sharedUtils.composeCallUrlEmail(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});
-    },
-
-    linkExfiltrated: false,
-
-    handleLinkExfiltration: function(event) {
-      // Update the count of shared URLs only once per generated URL.
-      if (!this.linkExfiltrated) {
-        this.linkExfiltrated = true;
-        try {
-          navigator.mozLoop.telemetryAdd("LOOP_CLIENT_CALL_URL_SHARED", true);
-        } catch (err) {
-          console.error("Error recording telemetry", err);
-        }
-      }
-
-      // Note URL expiration every time it is shared.
-      if (this.state.callUrlExpiry) {
-        navigator.mozLoop.noteCallUrlExpiry(this.state.callUrlExpiry);
-      }
-    },
-
-    render: function() {
-      // XXX setting elem value from a state (in the callUrl input)
-      // makes it immutable ie read only but that is fine in our case.
-      // readOnly attr will suppress a warning regarding this issue
-      // from the react lib.
-      var cx = React.addons.classSet;
-      return (
-        React.createElement("div", {className: "generate-url"}, 
-          React.createElement("header", {id: "share-link-header"}, mozL10n.get("share_link_header_text")), 
-          React.createElement("div", {className: "generate-url-stack"}, 
-            React.createElement("input", {type: "url", value: this.state.callUrl, readOnly: "true", 
-                   onCopy: this.handleLinkExfiltration, 
-                   className: cx({"generate-url-input": true,
-                                  pending: this.state.pending,
-                                  // Used in functional testing, signals that
-                                  // call url was received from loop server
-                                  callUrl: !this.state.pending})}), 
-            React.createElement("div", {className: cx({"generate-url-spinner": true,
-                                spinner: true,
-                                busy: this.state.pending})})
-          ), 
-          React.createElement(ButtonGroup, {additionalClass: "url-actions"}, 
-            React.createElement(Button, {additionalClass: "button-email", 
-                    disabled: !this.state.callUrl, 
-                    onClick: this.handleEmailButtonClick, 
-                    caption: mozL10n.get("share_button")}), 
-            React.createElement(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",
     mixins: [sharedMixins.WindowCloseMixin],
 
     handleSignUpLinkClick: function() {
       navigator.mozLoop.logInToFxA();
       this.closeWindow();
@@ -815,19 +662,17 @@ loop.panel = (function(_, mozL10n) {
   });
 
   /**
    * 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,
       // Used only for unit tests.
       showTabButtons: React.PropTypes.bool,
       selectedTab: React.PropTypes.string,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       mozLoop: React.PropTypes.object,
       roomStore:
         React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
@@ -864,27 +709,23 @@ loop.panel = (function(_, mozL10n) {
           detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
           detailsButtonCallback: serviceError.error.friendlyDetailsButtonCallback,
         });
       } else {
         this.props.notifications.remove(this.props.notifications.get("service-error"));
       }
     },
 
-    _roomsEnabled: function() {
-      return this.props.mozLoop.getLoopPref("rooms.enabled");
-    },
-
     _onStatusChanged: function() {
       var profile = this.props.mozLoop.userProfile;
       var currUid = this.state.userProfile ? this.state.userProfile.uid : null;
       var newUid = profile ? profile.uid : null;
       if (currUid != newUid) {
         // On profile change (login, logout), switch back to the default tab.
-        this.selectTab(this._roomsEnabled() ? "rooms" : "call");
+        this.selectTab("rooms");
         this.setState({userProfile: profile});
       }
       this.updateServiceErrors();
     },
 
     _gettingStartedSeen: function() {
       this.setState({
         gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen"),
@@ -897,44 +738,16 @@ loop.panel = (function(_, mozL10n) {
           this.selectTab(e.detail.tab);
           break;
         default:
           console.error("Invalid action", e.detail.action);
           break;
       }
     },
 
-    /**
-     * The rooms feature is hidden by default for now. Once it gets mainstream,
-     * this method can be simplified.
-     */
-    _renderRoomsOrCallTab: function() {
-      if (!this._roomsEnabled()) {
-        return (
-          React.createElement(Tab, {name: "call"}, 
-            React.createElement("div", {className: "content-area"}, 
-              React.createElement(CallUrlResult, {client: this.props.client, 
-                             notifications: this.props.notifications, 
-                             callUrl: this.props.callUrl}), 
-              React.createElement(ToSView, null)
-            )
-          )
-        );
-      }
-
-      return (
-        React.createElement(Tab, {name: "rooms"}, 
-          React.createElement(RoomList, {dispatcher: this.props.dispatcher, 
-                    store: this.props.roomStore, 
-                    userDisplayName: this._getUserDisplayName()}), 
-          React.createElement(ToSView, null)
-        )
-      );
-    },
-
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
 
     selectTab: function(name) {
       this.refs.tabView.setState({ selectedTab: name });
     },
@@ -981,20 +794,26 @@ loop.panel = (function(_, mozL10n) {
       }
 
       return (
         React.createElement("div", null, 
           React.createElement(NotificationListView, {notifications: this.props.notifications, 
                                 clearOnDocumentHidden: true}), 
           React.createElement(TabView, {ref: "tabView", selectedTab: this.props.selectedTab, 
             buttonsHidden: hideButtons}, 
-            this._renderRoomsOrCallTab(), 
+            React.createElement(Tab, {name: "rooms"}, 
+              React.createElement(RoomList, {dispatcher: this.props.dispatcher, 
+                        store: this.props.roomStore, 
+                        userDisplayName: this._getUserDisplayName()}), 
+              React.createElement(ToSView, null)
+            ), 
             React.createElement(Tab, {name: "contacts"}, 
               React.createElement(ContactsList, {selectTab: this.selectTab, 
-                            startForm: this.startForm})
+                            startForm: this.startForm, 
+                            notifications: this.props.notifications})
             ), 
             React.createElement(Tab, {name: "contacts_add", hidden: true}, 
               React.createElement(ContactDetailsForm, {ref: "contacts_add", mode: "add", 
                                   selectTab: this.selectTab})
             ), 
             React.createElement(Tab, {name: "contacts_edit", hidden: true}, 
               React.createElement(ContactDetailsForm, {ref: "contacts_edit", mode: "edit", 
                                   selectTab: this.selectTab})
@@ -1023,26 +842,24 @@ loop.panel = (function(_, mozL10n) {
   /**
    * 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);
 
-    var client = new loop.Client();
     var notifications = new sharedModels.NotificationCollection();
     var dispatcher = new loop.Dispatcher();
     var roomStore = new loop.store.RoomStore(dispatcher, {
       mozLoop: navigator.mozLoop,
       notifications: notifications
     });
 
     React.render(React.createElement(PanelView, {
-      client: client, 
       notifications: notifications, 
       roomStore: roomStore, 
       mozLoop: navigator.mozLoop, 
       dispatcher: dispatcher}
     ), document.querySelector("#main"));
 
     document.body.setAttribute("dir", mozL10n.getDirection());
 
@@ -1051,17 +868,16 @@ loop.panel = (function(_, mozL10n) {
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
-    CallUrlResult: CallUrlResult,
     GettingStartedView: GettingStartedView,
     PanelView: PanelView,
     RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView,
     UserIdentity: UserIdentity,
   };
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -34,19 +34,17 @@ loop.panel = (function(_, mozL10n) {
       };
     },
 
     getInitialState: function() {
       // XXX Work around props.selectedTab being undefined initially.
       // When we don't need to rely on the pref, this can move back to
       // getDefaultProps (bug 1100258).
       return {
-        selectedTab: this.props.selectedTab ||
-          (navigator.mozLoop.getLoopPref("rooms.enabled") ?
-            "rooms" : "call")
+        selectedTab: this.props.selectedTab || "rooms"
       };
     },
 
     handleSelectTab: function(event) {
       var tabName = event.target.dataset.tabName;
       this.setState({selectedTab: tabName});
     },
 
@@ -354,167 +352,16 @@ loop.panel = (function(_, mozL10n) {
                                    icon="help" />
           </ul>
         </div>
       );
     }
   });
 
   /**
-   * Call url result view.
-   */
-  var CallUrlResult = React.createClass({
-    mixins: [sharedMixins.DocumentVisibilityMixin],
-
-    propTypes: {
-      callUrl:        React.PropTypes.string,
-      callUrlExpiry:  React.PropTypes.number,
-      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();
-    },
-
-    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});
-      // XXX This is an empty string as a conversation identifier. Bug 1015938 implements
-      // a user-set string.
-      this.props.client.requestCallUrl("",
-                                       this._onCallUrlReceived);
-    },
-
-    _onCallUrlReceived: function(err, callUrlData) {
-      if (err) {
-        if (err.code != 401) {
-          // 401 errors are already handled in hawkRequest and show an error
-          // message about the session.
-          this.props.notifications.errorL10n("unable_retrieve_url");
-        }
-        this.setState(this.getInitialState());
-      } else {
-        try {
-          var callUrl = new window.URL(callUrlData.callUrl);
-          // XXX the current server vers does not implement the callToken field
-          // but it exists in the API. This workaround should be removed in the future
-          var token = callUrlData.callToken ||
-                      callUrl.pathname.split('/').pop();
-
-          // Now that a new URL is available, indicate it has not been shared.
-          this.linkExfiltrated = false;
-
-          this.setState({pending: false, copied: false,
-                         callUrl: callUrl.href,
-                         callUrlExpiry: callUrlData.expiresAt});
-        } catch(e) {
-          console.log(e);
-          this.props.notifications.errorL10n("unable_retrieve_url");
-          this.setState(this.getInitialState());
-        }
-      }
-    },
-
-    handleEmailButtonClick: function(event) {
-      this.handleLinkExfiltration(event);
-
-      sharedUtils.composeCallUrlEmail(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});
-    },
-
-    linkExfiltrated: false,
-
-    handleLinkExfiltration: function(event) {
-      // Update the count of shared URLs only once per generated URL.
-      if (!this.linkExfiltrated) {
-        this.linkExfiltrated = true;
-        try {
-          navigator.mozLoop.telemetryAdd("LOOP_CLIENT_CALL_URL_SHARED", true);
-        } catch (err) {
-          console.error("Error recording telemetry", err);
-        }
-      }
-
-      // Note URL expiration every time it is shared.
-      if (this.state.callUrlExpiry) {
-        navigator.mozLoop.noteCallUrlExpiry(this.state.callUrlExpiry);
-      }
-    },
-
-    render: function() {
-      // XXX setting elem value from a state (in the callUrl input)
-      // makes it immutable ie read only but that is fine in our case.
-      // readOnly attr will suppress a warning regarding this issue
-      // from the react lib.
-      var cx = React.addons.classSet;
-      return (
-        <div className="generate-url">
-          <header id="share-link-header">{mozL10n.get("share_link_header_text")}</header>
-          <div className="generate-url-stack">
-            <input type="url" value={this.state.callUrl} readOnly="true"
-                   onCopy={this.handleLinkExfiltration}
-                   className={cx({"generate-url-input": true,
-                                  pending: this.state.pending,
-                                  // Used in functional testing, signals that
-                                  // call url was received from loop server
-                                  callUrl: !this.state.pending})} />
-            <div className={cx({"generate-url-spinner": true,
-                                spinner: true,
-                                busy: this.state.pending})} />
-          </div>
-          <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({
     mixins: [sharedMixins.WindowCloseMixin],
 
     handleSignUpLinkClick: function() {
       navigator.mozLoop.logInToFxA();
       this.closeWindow();
@@ -815,19 +662,17 @@ loop.panel = (function(_, mozL10n) {
   });
 
   /**
    * Panel view.
    */
   var PanelView = React.createClass({
     propTypes: {
       notifications: React.PropTypes.object.isRequired,
-      client: React.PropTypes.object.isRequired,
       // Mostly used for UI components showcase and unit tests
-      callUrl: React.PropTypes.string,
       userProfile: React.PropTypes.object,
       // Used only for unit tests.
       showTabButtons: React.PropTypes.bool,
       selectedTab: React.PropTypes.string,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       mozLoop: React.PropTypes.object,
       roomStore:
         React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
@@ -864,27 +709,23 @@ loop.panel = (function(_, mozL10n) {
           detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
           detailsButtonCallback: serviceError.error.friendlyDetailsButtonCallback,
         });
       } else {
         this.props.notifications.remove(this.props.notifications.get("service-error"));
       }
     },
 
-    _roomsEnabled: function() {
-      return this.props.mozLoop.getLoopPref("rooms.enabled");
-    },
-
     _onStatusChanged: function() {
       var profile = this.props.mozLoop.userProfile;
       var currUid = this.state.userProfile ? this.state.userProfile.uid : null;
       var newUid = profile ? profile.uid : null;
       if (currUid != newUid) {
         // On profile change (login, logout), switch back to the default tab.
-        this.selectTab(this._roomsEnabled() ? "rooms" : "call");
+        this.selectTab("rooms");
         this.setState({userProfile: profile});
       }
       this.updateServiceErrors();
     },
 
     _gettingStartedSeen: function() {
       this.setState({
         gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen"),
@@ -897,44 +738,16 @@ loop.panel = (function(_, mozL10n) {
           this.selectTab(e.detail.tab);
           break;
         default:
           console.error("Invalid action", e.detail.action);
           break;
       }
     },
 
-    /**
-     * The rooms feature is hidden by default for now. Once it gets mainstream,
-     * this method can be simplified.
-     */
-    _renderRoomsOrCallTab: function() {
-      if (!this._roomsEnabled()) {
-        return (
-          <Tab name="call">
-            <div className="content-area">
-              <CallUrlResult client={this.props.client}
-                             notifications={this.props.notifications}
-                             callUrl={this.props.callUrl} />
-              <ToSView />
-            </div>
-          </Tab>
-        );
-      }
-
-      return (
-        <Tab name="rooms">
-          <RoomList dispatcher={this.props.dispatcher}
-                    store={this.props.roomStore}
-                    userDisplayName={this._getUserDisplayName()}/>
-          <ToSView />
-        </Tab>
-      );
-    },
-
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
 
     selectTab: function(name) {
       this.refs.tabView.setState({ selectedTab: name });
     },
@@ -981,20 +794,26 @@ loop.panel = (function(_, mozL10n) {
       }
 
       return (
         <div>
           <NotificationListView notifications={this.props.notifications}
                                 clearOnDocumentHidden={true} />
           <TabView ref="tabView" selectedTab={this.props.selectedTab}
             buttonsHidden={hideButtons}>
-            {this._renderRoomsOrCallTab()}
+            <Tab name="rooms">
+              <RoomList dispatcher={this.props.dispatcher}
+                        store={this.props.roomStore}
+                        userDisplayName={this._getUserDisplayName()}/>
+              <ToSView />
+            </Tab>
             <Tab name="contacts">
               <ContactsList selectTab={this.selectTab}
-                            startForm={this.startForm} />
+                            startForm={this.startForm}
+                            notifications={this.props.notifications} />
             </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} />
@@ -1023,26 +842,24 @@ loop.panel = (function(_, mozL10n) {
   /**
    * 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);
 
-    var client = new loop.Client();
     var notifications = new sharedModels.NotificationCollection();
     var dispatcher = new loop.Dispatcher();
     var roomStore = new loop.store.RoomStore(dispatcher, {
       mozLoop: navigator.mozLoop,
       notifications: notifications
     });
 
     React.render(<PanelView
-      client={client}
       notifications={notifications}
       roomStore={roomStore}
       mozLoop={navigator.mozLoop}
       dispatcher={dispatcher}
     />, document.querySelector("#main"));
 
     document.body.setAttribute("dir", mozL10n.getDirection());
 
@@ -1051,17 +868,16 @@ loop.panel = (function(_, mozL10n) {
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
-    CallUrlResult: CallUrlResult,
     GettingStartedView: GettingStartedView,
     PanelView: PanelView,
     RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView,
     UserIdentity: UserIdentity,
   };
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -159,16 +159,17 @@ loop.roomViews = (function(mozL10n) {
 
   /**
    * Desktop room conversation view.
    */
   var DesktopRoomConversationView = React.createClass({displayName: "DesktopRoomConversationView",
     mixins: [
       ActiveRoomStoreMixin,
       sharedMixins.DocumentTitleMixin,
+      sharedMixins.MediaSetupMixin,
       sharedMixins.RoomsAudioMixin
     ],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       feedbackStore:
         React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired,
     },
@@ -178,83 +179,32 @@ loop.roomViews = (function(mozL10n) {
         return React.createElement(DesktopRoomInvitationView, {
           roomStore: this.props.roomStore, 
           dispatcher: this.props.dispatcher}
         );
       }
       return null;
     },
 
-    componentDidMount: function() {
-      /**
-       * OT inserts inline styles into the markup. Using a listener for
-       * resize events helps us trigger a full width/height on the element
-       * so that they update to the correct dimensions.
-       * XXX: this should be factored as a mixin.
-       */
-      window.addEventListener('orientationchange', this.updateVideoContainer);
-      window.addEventListener('resize', this.updateVideoContainer);
-    },
-
     componentWillUpdate: function(nextProps, nextState) {
       // The SDK needs to know about the configuration and the elements to use
       // for display. So the best way seems to pass the information here - ideally
       // the sdk wouldn't need to know this, but we can't change that.
       if (this.state.roomState !== ROOM_STATES.MEDIA_WAIT &&
           nextState.roomState === ROOM_STATES.MEDIA_WAIT) {
         this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
-          publisherConfig: this._getPublisherConfig(),
+          publisherConfig: this.getDefaultPublisherConfig({
+            publishVideo: !this.state.videoMuted
+          }),
           getLocalElementFunc: this._getElement.bind(this, ".local"),
           getRemoteElementFunc: this._getElement.bind(this, ".remote")
         }));
       }
     },
 
-    _getPublisherConfig: function() {
-      // height set to 100%" to fix video layout on Google Chrome
-      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
-      return {
-        insertMode: "append",
-        width: "100%",
-        height: "100%",
-        publishVideo: !this.state.videoMuted,
-        style: {
-          audioLevelDisplayMode: "off",
-          bugDisplayMode: "off",
-          buttonDisplayMode: "off",
-          nameDisplayMode: "off",
-          videoDisabledDisplayMode: "off"
-        }
-      };
-    },
-
-    /**
-     * Used to update the video container whenever the orientation or size of the
-     * display area changes.
-     */
-    updateVideoContainer: function() {
-      var localStreamParent = this._getElement('.local .OT_publisher');
-      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
-      if (localStreamParent) {
-        localStreamParent.style.width = "100%";
-      }
-      if (remoteStreamParent) {
-        remoteStreamParent.style.height = "100%";
-      }
-    },
-
-    /**
-     * Returns either the required DOMNode
-     *
-     * @param {String} className The name of the class to get the element for.
-     */
-    _getElement: function(className) {
-      return this.getDOMNode().querySelector(className);
-    },
-
     /**
      * User clicked on the "Leave" button.
      */
     leaveRoom: function() {
       this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
     },
 
     /**
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -159,16 +159,17 @@ loop.roomViews = (function(mozL10n) {
 
   /**
    * Desktop room conversation view.
    */
   var DesktopRoomConversationView = React.createClass({
     mixins: [
       ActiveRoomStoreMixin,
       sharedMixins.DocumentTitleMixin,
+      sharedMixins.MediaSetupMixin,
       sharedMixins.RoomsAudioMixin
     ],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       feedbackStore:
         React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired,
     },
@@ -178,83 +179,32 @@ loop.roomViews = (function(mozL10n) {
         return <DesktopRoomInvitationView
           roomStore={this.props.roomStore}
           dispatcher={this.props.dispatcher}
         />;
       }
       return null;
     },
 
-    componentDidMount: function() {
-      /**
-       * OT inserts inline styles into the markup. Using a listener for
-       * resize events helps us trigger a full width/height on the element
-       * so that they update to the correct dimensions.
-       * XXX: this should be factored as a mixin.
-       */
-      window.addEventListener('orientationchange', this.updateVideoContainer);
-      window.addEventListener('resize', this.updateVideoContainer);
-    },
-
     componentWillUpdate: function(nextProps, nextState) {
       // The SDK needs to know about the configuration and the elements to use
       // for display. So the best way seems to pass the information here - ideally
       // the sdk wouldn't need to know this, but we can't change that.
       if (this.state.roomState !== ROOM_STATES.MEDIA_WAIT &&
           nextState.roomState === ROOM_STATES.MEDIA_WAIT) {
         this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
-          publisherConfig: this._getPublisherConfig(),
+          publisherConfig: this.getDefaultPublisherConfig({
+            publishVideo: !this.state.videoMuted
+          }),
           getLocalElementFunc: this._getElement.bind(this, ".local"),
           getRemoteElementFunc: this._getElement.bind(this, ".remote")
         }));
       }
     },
 
-    _getPublisherConfig: function() {
-      // height set to 100%" to fix video layout on Google Chrome
-      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
-      return {
-        insertMode: "append",
-        width: "100%",
-        height: "100%",
-        publishVideo: !this.state.videoMuted,
-        style: {
-          audioLevelDisplayMode: "off",
-          bugDisplayMode: "off",
-          buttonDisplayMode: "off",
-          nameDisplayMode: "off",
-          videoDisabledDisplayMode: "off"
-        }
-      };
-    },
-
-    /**
-     * Used to update the video container whenever the orientation or size of the
-     * display area changes.
-     */
-    updateVideoContainer: function() {
-      var localStreamParent = this._getElement('.local .OT_publisher');
-      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
-      if (localStreamParent) {
-        localStreamParent.style.width = "100%";
-      }
-      if (remoteStreamParent) {
-        remoteStreamParent.style.height = "100%";
-      }
-    },
-
-    /**
-     * Returns either the required DOMNode
-     *
-     * @param {String} className The name of the class to get the element for.
-     */
-    _getElement: function(className) {
-      return this.getDOMNode().querySelector(className);
-    },
-
     /**
      * User clicked on the "Leave" button.
      */
     leaveRoom: function() {
       this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
     },
 
     /**
--- a/browser/components/loop/content/panel.html
+++ b/browser/components/loop/content/panel.html
@@ -27,13 +27,12 @@
     <script type="text/javascript" src="loop/shared/js/validate.js"></script>
     <script type="text/javascript" src="loop/shared/js/actions.js"></script>
     <script type="text/javascript" src="loop/shared/js/dispatcher.js"></script>
     <script type="text/javascript" src="loop/shared/js/store.js"></script>
     <script type="text/javascript" src="loop/shared/js/roomStore.js"></script>
     <script type="text/javascript" src="loop/shared/js/roomStates.js"></script>
     <script type="text/javascript" src="loop/shared/js/fxOSActiveRoomStore.js"></script>
     <script type="text/javascript" src="loop/shared/js/activeRoomStore.js"></script>
-    <script type="text/javascript" src="loop/js/client.js"></script>
     <script type="text/javascript;version=1.8" src="loop/js/contacts.js"></script>
     <script type="text/javascript" src="loop/js/panel.js"></script>
  </body>
 </html>
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -258,16 +258,22 @@ p {
   color: #fff;
 }
 
 .alert-warning {
   background: #fcf8e3;
   border: 1px solid #fbeed5;
 }
 
+.alert-success {
+  background: #5BC0A4;
+  border: 1px solid #5BC0A4;
+  color: #fff;
+}
+
 .notificationContainer > .details-error {
   background: #fbebeb;
   color: #d74345
 }
 
 .notificationContainer > .details-error > .detailsButton {
   float: right;
   -moz-margin-start: 1em; /* Match .detailsBar padding */
--- a/browser/components/loop/content/shared/js/mixins.js
+++ b/browser/components/loop/content/shared/js/mixins.js
@@ -148,16 +148,85 @@ loop.shared.mixins = (function() {
 
     componentWillUnmount: function() {
       rootObject.document.removeEventListener(
         "visibilitychange", this._onDocumentVisibilityChanged);
     }
   };
 
   /**
+   * Media setup mixin. Provides a common location for settings for the media
+   * elements and handling updates of the media containers.
+   */
+  var MediaSetupMixin = {
+    componentDidMount: function() {
+      rootObject.addEventListener('orientationchange', this.updateVideoContainer);
+      rootObject.addEventListener('resize', this.updateVideoContainer);
+    },
+
+    componentWillUnmount: function() {
+      rootObject.removeEventListener('orientationchange', this.updateVideoContainer);
+      rootObject.removeEventListener('resize', this.updateVideoContainer);
+    },
+
+    /**
+     * Used to update the video container whenever the orientation or size of the
+     * display area changes.
+     */
+    updateVideoContainer: function() {
+      var localStreamParent = this._getElement('.local .OT_publisher');
+      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
+    },
+
+    /**
+     * Returns the default configuration for publishing media on the sdk.
+     *
+     * @param {Object} options An options object containing:
+     * - publishVideo A boolean set to true to publish video when the stream is initiated.
+     */
+    getDefaultPublisherConfig: function(options) {
+      options = options || {};
+      if (!"publishVideo" in options) {
+        throw new Error("missing option publishVideo");
+      }
+
+      // height set to 100%" to fix video layout on Google Chrome
+      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
+      return {
+        insertMode: "append",
+        width: "100%",
+        height: "100%",
+        publishVideo: options.publishVideo,
+        style: {
+          audioLevelDisplayMode: "off",
+          bugDisplayMode: "off",
+          buttonDisplayMode: "off",
+          nameDisplayMode: "off",
+          videoDisabledDisplayMode: "off"
+        }
+      };
+    },
+
+    /**
+     * Returns either the required DOMNode
+     *
+     * @param {String} className The name of the class to get the element for.
+     */
+    _getElement: function(className) {
+      return this.getDOMNode().querySelector(className);
+    }
+  };
+
+  /**
    * Audio mixin. Allows playing a single audio file and ensuring it
    * is stopped when the component is unmounted.
    */
   var AudioMixin = {
     audio: null,
     _audioRequest: null,
 
     _isLoopDesktop: function() {
@@ -303,12 +372,13 @@ loop.shared.mixins = (function() {
   return {
     AudioMixin: AudioMixin,
     RoomsAudioMixin: RoomsAudioMixin,
     setRootObject: setRootObject,
     DropdownMenuMixin: DropdownMenuMixin,
     DocumentVisibilityMixin: DocumentVisibilityMixin,
     DocumentLocationMixin: DocumentLocationMixin,
     DocumentTitleMixin: DocumentTitleMixin,
+    MediaSetupMixin: MediaSetupMixin,
     UrlHashChangeMixin: UrlHashChangeMixin,
     WindowCloseMixin: WindowCloseMixin
   };
 })();
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -417,16 +417,37 @@ loop.shared.models = (function(l10n) {
      *
      * @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));
+    },
+
+    /**
+     * Adds a success notification to the stack and renders it.
+     *
+     * @return {String} message
+     */
+    success: function(message) {
+      this.add({level: "success", message: message});
+    },
+
+    /**
+     * Adds a l10n success 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.
+     */
+    successL10n: function(messageId, l10nProps) {
+      this.success(l10n.get(messageId, l10nProps));
     }
   });
 
   return {
     ConversationModel: ConversationModel,
     NotificationCollection: NotificationCollection,
     NotificationModel: NotificationModel
   };
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -136,93 +136,56 @@ loop.shared.views = (function(_, OT, l10
       );
     }
   });
 
   /**
    * Conversation view.
    */
   var ConversationView = React.createClass({displayName: "ConversationView",
-    mixins: [Backbone.Events, sharedMixins.AudioMixin],
+    mixins: [
+      Backbone.Events,
+      sharedMixins.AudioMixin,
+      sharedMixins.MediaSetupMixin
+    ],
 
     propTypes: {
       sdk: 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: {
-        audioLevelDisplayMode: "off",
-        bugDisplayMode: "off",
-        buttonDisplayMode: "off",
-        nameDisplayMode: "off",
-        videoDisabledDisplayMode: "off"
-      }
-    },
-
     getDefaultProps: function() {
       return {
         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() {
-      if (this.props.initiate) {
-        this.publisherConfig.publishVideo = this.props.video.enabled;
-      }
-    },
-
     componentDidMount: function() {
       if (this.props.initiate) {
         this.listenTo(this.props.model, "session:connected",
                                         this._onSessionConnected);
         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();
       }
-
-      /**
-       * 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();
     },
 
@@ -243,32 +206,35 @@ loop.shared.views = (function(_, OT, l10
      *      element.
      *
      * http://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html
      *
      * @param  {StreamEvent} event
      */
     _streamCreated: function(event) {
       var incoming = this.getDOMNode().querySelector(".remote");
-      this.props.model.subscribe(event.stream, incoming, this.publisherConfig);
+      this.props.model.subscribe(event.stream, incoming,
+        this.getDefaultPublisherConfig({
+          publishVideo: this.props.video.enabled
+        }));
     },
 
     /**
      * Publishes remote streams available once a session is connected.
      *
      * http://tokbox.com/opentok/libraries/client/js/reference/SessionConnectEvent.html
      *
      * @param  {SessionConnectEvent} event
      */
     startPublishing: function(event) {
       var outgoing = this.getDOMNode().querySelector(".local");
 
       // XXX move this into its StreamingVideo component?
       this.publisher = this.props.sdk.initPublisher(
-        outgoing, this.publisherConfig);
+        outgoing, this.getDefaultPublisherConfig({publishVideo: this.props.video.enabled}));
 
       // Suppress OT GuM custom dialog, see bug 1018875
       this.listenTo(this.publisher, "accessDialogOpened accessDenied",
                     function(event) {
                       event.preventDefault();
                     });
 
       this.listenTo(this.publisher, "streamCreated", function(event) {
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -136,93 +136,56 @@ loop.shared.views = (function(_, OT, l10
       );
     }
   });
 
   /**
    * Conversation view.
    */
   var ConversationView = React.createClass({
-    mixins: [Backbone.Events, sharedMixins.AudioMixin],
+    mixins: [
+      Backbone.Events,
+      sharedMixins.AudioMixin,
+      sharedMixins.MediaSetupMixin
+    ],
 
     propTypes: {
       sdk: 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: {
-        audioLevelDisplayMode: "off",
-        bugDisplayMode: "off",
-        buttonDisplayMode: "off",
-        nameDisplayMode: "off",
-        videoDisabledDisplayMode: "off"
-      }
-    },
-
     getDefaultProps: function() {
       return {
         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() {
-      if (this.props.initiate) {
-        this.publisherConfig.publishVideo = this.props.video.enabled;
-      }
-    },
-
     componentDidMount: function() {
       if (this.props.initiate) {
         this.listenTo(this.props.model, "session:connected",
                                         this._onSessionConnected);
         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();
       }
-
-      /**
-       * 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();
     },
 
@@ -243,32 +206,35 @@ loop.shared.views = (function(_, OT, l10
      *      element.
      *
      * http://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html
      *
      * @param  {StreamEvent} event
      */
     _streamCreated: function(event) {
       var incoming = this.getDOMNode().querySelector(".remote");
-      this.props.model.subscribe(event.stream, incoming, this.publisherConfig);
+      this.props.model.subscribe(event.stream, incoming,
+        this.getDefaultPublisherConfig({
+          publishVideo: this.props.video.enabled
+        }));
     },
 
     /**
      * Publishes remote streams available once a session is connected.
      *
      * http://tokbox.com/opentok/libraries/client/js/reference/SessionConnectEvent.html
      *
      * @param  {SessionConnectEvent} event
      */
     startPublishing: function(event) {
       var outgoing = this.getDOMNode().querySelector(".local");
 
       // XXX move this into its StreamingVideo component?
       this.publisher = this.props.sdk.initPublisher(
-        outgoing, this.publisherConfig);
+        outgoing, this.getDefaultPublisherConfig({publishVideo: this.props.video.enabled}));
 
       // Suppress OT GuM custom dialog, see bug 1018875
       this.listenTo(this.publisher, "accessDialogOpened accessDenied",
                     function(event) {
                       event.preventDefault();
                     });
 
       this.listenTo(this.publisher, "streamCreated", function(event) {
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -189,16 +189,17 @@ loop.standaloneRoomViews = (function(moz
         )
       );
     }
   });
 
   var StandaloneRoomView = React.createClass({displayName: "StandaloneRoomView",
     mixins: [
       Backbone.Events,
+      sharedMixins.MediaSetupMixin,
       sharedMixins.RoomsAudioMixin
     ],
 
     propTypes: {
       activeRoomStore: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
         React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
       ]).isRequired,
@@ -226,71 +227,17 @@ loop.standaloneRoomViews = (function(moz
      * to match the store.
      *
      * @private
      */
     _onActiveRoomStateChanged: function() {
       this.setState(this.props.activeRoomStore.getStoreState());
     },
 
-    /**
-     * Returns either the required DOMNode
-     *
-     * @param {String} className The name of the class to get the element for.
-     */
-    _getElement: function(className) {
-      return this.getDOMNode().querySelector(className);
-    },
-
-     /**
-     * Returns the required configuration for publishing video on the sdk.
-     */
-    _getPublisherConfig: function() {
-      // height set to 100%" to fix video layout on Google Chrome
-      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
-      return {
-        insertMode: "append",
-        width: "100%",
-        height: "100%",
-        publishVideo: true,
-        style: {
-          audioLevelDisplayMode: "off",
-          bugDisplayMode: "off",
-          buttonDisplayMode: "off",
-          nameDisplayMode: "off",
-          videoDisabledDisplayMode: "off"
-        }
-      };
-    },
-
-    /**
-     * Used to update the video container whenever the orientation or size of the
-     * display area changes.
-     */
-    updateVideoContainer: function() {
-      var localStreamParent = this._getElement('.local .OT_publisher');
-      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
-      if (localStreamParent) {
-        localStreamParent.style.width = "100%";
-      }
-      if (remoteStreamParent) {
-        remoteStreamParent.style.height = "100%";
-      }
-    },
-
     componentDidMount: function() {
-      /**
-       * OT inserts inline styles into the markup. Using a listener for
-       * resize events helps us trigger a full width/height on the element
-       * so that they update to the correct dimensions.
-       * XXX: this should be factored as a mixin, bug 1104930
-       */
-      window.addEventListener('orientationchange', this.updateVideoContainer);
-      window.addEventListener('resize', this.updateVideoContainer);
-
       // Adding a class to the document body element from here to ease styling it.
       document.body.classList.add("is-standalone-room");
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.activeRoomStore);
     },
 
@@ -300,17 +247,17 @@ loop.standaloneRoomViews = (function(moz
      *
      * @param  {Object} nextProps (Unused)
      * @param  {Object} nextState Next state object.
      */
     componentWillUpdate: function(nextProps, nextState) {
       if (this.state.roomState !== ROOM_STATES.MEDIA_WAIT &&
           nextState.roomState === ROOM_STATES.MEDIA_WAIT) {
         this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
-          publisherConfig: this._getPublisherConfig(),
+          publisherConfig: this.getDefaultPublisherConfig({publishVideo: true}),
           getLocalElementFunc: this._getElement.bind(this, ".local"),
           getRemoteElementFunc: this._getElement.bind(this, ".remote")
         }));
       }
 
       if (this.state.roomState !== ROOM_STATES.JOINED &&
           nextState.roomState === ROOM_STATES.JOINED) {
         // This forces the video size to update - creating the publisher
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -189,16 +189,17 @@ loop.standaloneRoomViews = (function(moz
         </footer>
       );
     }
   });
 
   var StandaloneRoomView = React.createClass({
     mixins: [
       Backbone.Events,
+      sharedMixins.MediaSetupMixin,
       sharedMixins.RoomsAudioMixin
     ],
 
     propTypes: {
       activeRoomStore: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
         React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
       ]).isRequired,
@@ -226,71 +227,17 @@ loop.standaloneRoomViews = (function(moz
      * to match the store.
      *
      * @private
      */
     _onActiveRoomStateChanged: function() {
       this.setState(this.props.activeRoomStore.getStoreState());
     },
 
-    /**
-     * Returns either the required DOMNode
-     *
-     * @param {String} className The name of the class to get the element for.
-     */
-    _getElement: function(className) {
-      return this.getDOMNode().querySelector(className);
-    },
-
-     /**
-     * Returns the required configuration for publishing video on the sdk.
-     */
-    _getPublisherConfig: function() {
-      // height set to 100%" to fix video layout on Google Chrome
-      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
-      return {
-        insertMode: "append",
-        width: "100%",
-        height: "100%",
-        publishVideo: true,
-        style: {
-          audioLevelDisplayMode: "off",
-          bugDisplayMode: "off",
-          buttonDisplayMode: "off",
-          nameDisplayMode: "off",
-          videoDisabledDisplayMode: "off"
-        }
-      };
-    },
-
-    /**
-     * Used to update the video container whenever the orientation or size of the
-     * display area changes.
-     */
-    updateVideoContainer: function() {
-      var localStreamParent = this._getElement('.local .OT_publisher');
-      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
-      if (localStreamParent) {
-        localStreamParent.style.width = "100%";
-      }
-      if (remoteStreamParent) {
-        remoteStreamParent.style.height = "100%";
-      }
-    },
-
     componentDidMount: function() {
-      /**
-       * OT inserts inline styles into the markup. Using a listener for
-       * resize events helps us trigger a full width/height on the element
-       * so that they update to the correct dimensions.
-       * XXX: this should be factored as a mixin, bug 1104930
-       */
-      window.addEventListener('orientationchange', this.updateVideoContainer);
-      window.addEventListener('resize', this.updateVideoContainer);
-
       // Adding a class to the document body element from here to ease styling it.
       document.body.classList.add("is-standalone-room");
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.activeRoomStore);
     },
 
@@ -300,17 +247,17 @@ loop.standaloneRoomViews = (function(moz
      *
      * @param  {Object} nextProps (Unused)
      * @param  {Object} nextState Next state object.
      */
     componentWillUpdate: function(nextProps, nextState) {
       if (this.state.roomState !== ROOM_STATES.MEDIA_WAIT &&
           nextState.roomState === ROOM_STATES.MEDIA_WAIT) {
         this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
-          publisherConfig: this._getPublisherConfig(),
+          publisherConfig: this.getDefaultPublisherConfig({publishVideo: true}),
           getLocalElementFunc: this._getElement.bind(this, ".local"),
           getRemoteElementFunc: this._getElement.bind(this, ".remote")
         }));
       }
 
       if (this.state.roomState !== ROOM_STATES.JOINED &&
           nextState.roomState === ROOM_STATES.JOINED) {
         // This forces the video size to update - creating the publisher
--- a/browser/components/loop/test/desktop-local/client_test.js
+++ b/browser/components/loop/test/desktop-local/client_test.js
@@ -27,17 +27,16 @@ describe("loop.Client", function() {
     sandbox = sinon.sandbox.create();
     callback = sinon.spy();
     fakeToken = "fakeTokenText";
     mozLoop = {
       getLoopPref: sandbox.stub()
         .returns(null)
         .withArgs("hawk-session-token")
         .returns(fakeToken),
-      noteCallUrlExpiry: sinon.spy(),
       hawkRequest: sinon.stub(),
       LOOP_SESSION_TYPE: {
         GUEST: 1,
         FXA: 2
       },
       userProfile: null,
       telemetryAdd: sinon.spy()
     };
@@ -84,150 +83,16 @@ describe("loop.Client", function() {
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
           return err.code == 400 && "invalid token" == err.message;
         }));
       });
     });
 
-    describe("#requestCallUrl", function() {
-      it("should post to /call-url/", function() {
-        client.requestCallUrl("foo", callback);
-
-        sinon.assert.calledOnce(hawkRequestStub);
-        sinon.assert.calledWithExactly(hawkRequestStub, sinon.match.number,
-          "/call-url/", "POST", {callerId: "foo"}, sinon.match.func);
-      });
-
-      it("should send a sessionType of LOOP_SESSION_TYPE.GUEST when " +
-         "mozLoop.userProfile returns null", function() {
-        mozLoop.userProfile = null;
-
-        client.requestCallUrl("foo", callback);
-
-        sinon.assert.calledOnce(hawkRequestStub);
-        sinon.assert.calledWithExactly(hawkRequestStub,
-          mozLoop.LOOP_SESSION_TYPE.GUEST, "/call-url/", "POST",
-          {callerId: "foo"}, sinon.match.func);
-      });
-
-      it("should send a sessionType of LOOP_SESSION_TYPE.FXA when " +
-         "mozLoop.userProfile returns an object", function () {
-        mozLoop.userProfile = {};
-
-        client.requestCallUrl("foo", callback);
-
-        sinon.assert.calledOnce(hawkRequestStub);
-        sinon.assert.calledWithExactly(hawkRequestStub,
-          mozLoop.LOOP_SESSION_TYPE.FXA, "/call-url/", "POST",
-          {callerId: "foo"}, sinon.match.func);
-      });
-
-      it("should call the callback with the url when the request succeeds",
-        function() {
-          var callUrlData = {
-            "callUrl": "fakeCallUrl",
-            "expiresAt": 60
-          };
-
-          // Sets up the hawkRequest stub to trigger the callback with no error
-          // and the url.
-          hawkRequestStub.callsArgWith(4, null, JSON.stringify(callUrlData));
-
-          client.requestCallUrl("foo", callback);
-
-          sinon.assert.calledWithExactly(callback, null, callUrlData);
-        });
-
-      it("should not update call url expiry when the request succeeds",
-        function() {
-          var callUrlData = {
-            "callUrl": "fakeCallUrl",
-            "expiresAt": 6000
-          };
-
-          // Sets up the hawkRequest stub to trigger the callback with no error
-          // and the url.
-          hawkRequestStub.callsArgWith(4, null, JSON.stringify(callUrlData));
-
-          client.requestCallUrl("foo", callback);
-
-          sinon.assert.notCalled(mozLoop.noteCallUrlExpiry);
-        });
-
-      it("should call mozLoop.telemetryAdd when the request succeeds",
-        function(done) {
-          var callUrlData = {
-            "callUrl": "fakeCallUrl",
-            "expiresAt": 60
-          };
-
-          // Sets up the hawkRequest stub to trigger the callback with no error
-          // and the url.
-          hawkRequestStub.callsArgWith(4, null,
-            JSON.stringify(callUrlData));
-
-          client.requestCallUrl("foo", function(err) {
-            expect(err).to.be.null;
-
-            sinon.assert.calledOnce(mozLoop.telemetryAdd);
-            sinon.assert.calledWith(mozLoop.telemetryAdd,
-                                    "LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS",
-                                    true);
-
-            done();
-          });
-        });
-
-      it("should send an error when the request fails", function() {
-        // Sets up the hawkRequest stub to trigger the callback with
-        // an error
-        hawkRequestStub.callsArgWith(4, fakeErrorRes);
-
-        client.requestCallUrl("foo", callback);
-
-        sinon.assert.calledOnce(callback);
-        sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
-          return err.code == 400 && "invalid token" == err.message;
-        }));
-      });
-
-      it("should send an error if the data is not valid", function() {
-        // Sets up the hawkRequest stub to trigger the callback with
-        // an error
-        hawkRequestStub.callsArgWith(4, null, "{}");
-
-        client.requestCallUrl("foo", callback);
-
-        sinon.assert.calledOnce(callback);
-        sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
-          return /Invalid data received/.test(err.message);
-        }));
-      });
-
-      it("should call mozLoop.telemetryAdd when the request fails",
-        function(done) {
-          // Sets up the hawkRequest stub to trigger the callback with
-          // an error
-          hawkRequestStub.callsArgWith(4, fakeErrorRes);
-
-          client.requestCallUrl("foo", function(err) {
-            expect(err).not.to.be.null;
-
-            sinon.assert.calledOnce(mozLoop.telemetryAdd);
-            sinon.assert.calledWith(mozLoop.telemetryAdd,
-                                    "LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS",
-                                    false);
-
-            done();
-          });
-        });
-    });
-
     describe("#setupOutgoingCall", function() {
       var calleeIds, callType;
 
       beforeEach(function() {
         calleeIds = [
           "fakeemail", "fake phone"
         ];
         callType = "audio";
--- a/browser/components/loop/test/desktop-local/contacts_test.js
+++ b/browser/components/loop/test/desktop-local/contacts_test.js
@@ -11,16 +11,17 @@ var TestUtils = React.addons.TestUtils;
 describe("loop.contacts", function() {
   "use strict";
 
   var fakeAddContactButtonText = "Fake Add Contact";
   var fakeEditContactButtonText = "Fake Edit Contact";
   var fakeDoneButtonText = "Fake Done";
   var sandbox;
   var fakeWindow;
+  var notifications;
 
   beforeEach(function(done) {
     sandbox = sinon.sandbox.create();
     navigator.mozLoop = {
       getStrings: function(entityName) {
         var textContentValue = "fakeText";
         if (entityName == "add_contact_button") {
           textContentValue = fakeAddContactButtonText;
@@ -54,18 +55,21 @@ describe("loop.contacts", function() {
 
     beforeEach(function() {
       navigator.mozLoop.calls = {
         startDirectCall: sandbox.stub(),
         clearCallInProgress: sandbox.stub()
       };
       navigator.mozLoop.contacts = {getAll: sandbox.stub()};
 
+      notifications = new loop.shared.models.NotificationCollection();
       listView = TestUtils.renderIntoDocument(
-        React.createElement(loop.contacts.ContactsList));
+        React.createElement(loop.contacts.ContactsList, {
+          notifications: notifications
+        }));
     });
 
     afterEach(function() {
       listView = null;
       delete navigator.mozLoop.calls;
       delete navigator.mozLoop.contacts;
     });
 
@@ -79,16 +83,44 @@ describe("loop.contacts", function() {
 
       it("should call window.close when called with 'audio-call' action",
         function() {
           listView.handleContactAction({}, "audio-call");
 
           sinon.assert.calledOnce(fakeWindow.close);
         });
     });
+
+    describe("#handleImportButtonClick", function() {
+      it("should notify the end user from a succesful import", function() {
+        sandbox.stub(notifications, "successL10n");
+        navigator.mozLoop.startImport = function(opts, cb) {
+          cb(null, {total: 42});
+        };
+
+        listView.handleImportButtonClick();
+
+        sinon.assert.calledWithExactly(
+          notifications.successL10n,
+          "import_contacts_success_message",
+          {total: 42});
+      });
+
+      it("should notify the end user from any encountered error", function() {
+        sandbox.stub(notifications, "errorL10n");
+        navigator.mozLoop.startImport = function(opts, cb) {
+          cb(new Error("fake error"));
+        };
+
+        listView.handleImportButtonClick();
+
+        sinon.assert.calledWithExactly(notifications.errorL10n,
+                                       "import_contacts_failure_message");
+      });
+    });
   });
 
   describe("ContactDetailsForm", function() {
     describe("#render", function() {
       describe("add mode", function() {
         var view;
 
         beforeEach(function() {
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -87,17 +87,19 @@ describe("loop.conversationViews", funct
           return"http://fakeurl";
         }
 
         return false;
     });
 
     fakeWindow = {
       navigator: { mozLoop: fakeMozLoop },
-      close: sandbox.stub(),
+      close: sinon.stub(),
+      addEventListener: function() {},
+      removeEventListener: function() {}
     };
     loop.shared.mixins.setRootObject(fakeWindow);
 
   });
 
   afterEach(function() {
     loop.shared.mixins.setRootObject(window);
     document.title = oldTitle;
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -48,17 +48,19 @@ describe("loop.conversation", function()
       },
       getAudioBlob: sinon.spy(function(name, callback) {
         callback(null, new Blob([new ArrayBuffer(10)], {type: 'audio/ogg'}));
       })
     };
 
     fakeWindow = {
       navigator: { mozLoop: navigator.mozLoop },
-      close: sandbox.stub(),
+      close: sinon.stub(),
+      addEventListener: function() {},
+      removeEventListener: function() {}
     };
     loop.shared.mixins.setRootObject(fakeWindow);
 
     // XXX These stubs should be hoisted in a common file
     // Bug 1040968
     sandbox.stub(document.mozL10n, "get", function(x) {
       return x;
     });
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -43,20 +43,16 @@ describe("loop.panel", function() {
       get locale() {
         return "en-US";
       },
       setLoopPref: sandbox.stub(),
       getLoopPref: sandbox.stub().returns("unseen"),
       getPluralForm: function() {
         return "fakeText";
       },
-      copyString: sandbox.stub(),
-      noteCallUrlExpiry: sinon.spy(),
-      composeEmail: sinon.spy(),
-      telemetryAdd: sinon.spy(),
       contacts: {
         getAll: function(callback) {
           callback(null, []);
         },
         on: sandbox.stub()
       },
       rooms: {
         getAll: function(version, callback) {
@@ -181,79 +177,43 @@ describe("loop.panel", function() {
           dispatcher: dispatcher,
           roomStore: roomStore
         }));
     }
 
     describe('TabView', function() {
       var view, callTab, roomsTab, contactsTab;
 
-      describe("loop.rooms.enabled on", function() {
-        beforeEach(function() {
-          navigator.mozLoop.getLoopPref = function(pref) {
-            if (pref === "rooms.enabled" ||
-                pref === "gettingStarted.seen") {
-              return true;
-            }
-          };
-
-          view = createTestPanelView();
-
-          [roomsTab, contactsTab] =
-            TestUtils.scryRenderedDOMComponentsWithClass(view, "tab");
-        });
+      beforeEach(function() {
+        navigator.mozLoop.getLoopPref = function(pref) {
+          if (pref === "gettingStarted.seen") {
+            return true;
+          }
+        };
 
-        it("should select contacts tab when clicking tab button", function() {
-          TestUtils.Simulate.click(
-            view.getDOMNode().querySelector("li[data-tab-name=\"contacts\"]"));
-
-          expect(contactsTab.getDOMNode().classList.contains("selected"))
-            .to.be.true;
-        });
+        view = createTestPanelView();
 
-        it("should select rooms tab when clicking tab button", function() {
-          TestUtils.Simulate.click(
-            view.getDOMNode().querySelector("li[data-tab-name=\"rooms\"]"));
-
-          expect(roomsTab.getDOMNode().classList.contains("selected"))
-            .to.be.true;
-        });
+        [roomsTab, contactsTab] =
+          TestUtils.scryRenderedDOMComponentsWithClass(view, "tab");
       });
 
-      describe("loop.rooms.enabled off", function() {
-        beforeEach(function() {
-          navigator.mozLoop.getLoopPref = function(pref) {
-            if (pref === "rooms.enabled") {
-              return false;
-            } else if (pref === "gettingStarted.seen") {
-              return true;
-            }
-          };
-
-          view = createTestPanelView();
-
-          [callTab, contactsTab] =
-            TestUtils.scryRenderedDOMComponentsWithClass(view, "tab");
-        });
+      it("should select contacts tab when clicking tab button", function() {
+        TestUtils.Simulate.click(
+          view.getDOMNode().querySelector("li[data-tab-name=\"contacts\"]"));
 
-        it("should select contacts tab when clicking tab button", function() {
-          TestUtils.Simulate.click(
-            view.getDOMNode().querySelector("li[data-tab-name=\"contacts\"]"));
-
-          expect(contactsTab.getDOMNode().classList.contains("selected"))
-            .to.be.true;
-        });
+        expect(contactsTab.getDOMNode().classList.contains("selected"))
+          .to.be.true;
+      });
 
-        it("should select call tab when clicking tab button", function() {
-          TestUtils.Simulate.click(
-            view.getDOMNode().querySelector("li[data-tab-name=\"call\"]"));
+      it("should select rooms tab when clicking tab button", function() {
+        TestUtils.Simulate.click(
+          view.getDOMNode().querySelector("li[data-tab-name=\"rooms\"]"));
 
-          expect(callTab.getDOMNode().classList.contains("selected"))
-            .to.be.true;
-        });
+        expect(roomsTab.getDOMNode().classList.contains("selected"))
+          .to.be.true;
       });
     });
 
     describe("AuthLink", function() {
 
       beforeEach(function() {
         navigator.mozLoop.calls = { clearCallInProgress: function() {} };
       });
@@ -463,294 +423,16 @@ describe("loop.panel", function() {
           TestUtils.findRenderedComponentWithType(view, loop.panel.GettingStartedView);
           sinon.assert.fail("Should not find the GettingStartedView if it has been seen");
         } catch (ex) {}
       });
 
     });
   });
 
-  describe("loop.panel.CallUrlResult", function() {
-    var fakeClient, callUrlData, view;
-
-    beforeEach(function() {
-      callUrlData = {
-        callUrl: "http://call.invalid/fakeToken",
-        expiresAt: 1000
-      };
-
-      fakeClient = {
-        requestCallUrl: function(_, cb) {
-          cb(null, callUrlData);
-        }
-      };
-
-      sandbox.stub(notifications, "reset");
-      view = TestUtils.renderIntoDocument(
-        React.createElement(loop.panel.CallUrlResult, {
-          notifications: notifications,
-          client: fakeClient
-        }));
-    });
-
-    describe("Rendering the component should generate a call URL", function() {
-
-      beforeEach(function() {
-        document.mozL10n.initialize({
-          getStrings: function(key) {
-            var text;
-
-            if (key === "share_email_subject4")
-              text = "email-subject";
-            else if (key === "share_email_body4")
-              text = "{{callUrl}}";
-
-            return JSON.stringify({textContent: text});
-          }
-        });
-      });
-
-      it("should make a request to requestCallUrl", function() {
-        sandbox.stub(fakeClient, "requestCallUrl");
-        var view = TestUtils.renderIntoDocument(
-          React.createElement(loop.panel.CallUrlResult, {
-            notifications: notifications,
-            client: fakeClient
-          }));
-
-        sinon.assert.calledOnce(view.props.client.requestCallUrl);
-        sinon.assert.calledWithExactly(view.props.client.requestCallUrl,
-                                       sinon.match.string, sinon.match.func);
-      });
-
-      it("should set the call url form in a pending state", function() {
-        // Cancel requestCallUrl effect to keep the state pending
-        fakeClient.requestCallUrl = sandbox.stub();
-        var view = TestUtils.renderIntoDocument(
-          React.createElement(loop.panel.CallUrlResult, {
-          notifications: notifications,
-          client: fakeClient
-        }));
-
-        expect(view.state.pending).eql(true);
-      });
-
-      it("should update state with the call url received", function() {
-        expect(view.state.pending).eql(false);
-        expect(view.state.callUrl).eql(callUrlData.callUrl);
-      });
-
-      it("should clear the pending state when a response is received",
-        function() {
-          expect(view.state.pending).eql(false);
-        });
-
-      it("should update CallUrlResult with the call url", function() {
-        var urlField = view.getDOMNode().querySelector("input[type='url']");
-
-        expect(urlField.value).eql(callUrlData.callUrl);
-      });
-
-      it("should have 0 pending notifications", function() {
-        expect(view.props.notifications.length).eql(0);
-      });
-
-      it("should display a share button for email", function() {
-        fakeClient.requestCallUrl = sandbox.stub();
-        var composeCallUrlEmail = sandbox.stub(sharedUtils, "composeCallUrlEmail");
-        var view = TestUtils.renderIntoDocument(
-          React.createElement(loop.panel.CallUrlResult, {
-          notifications: notifications,
-          client: fakeClient
-        }));
-        view.setState({pending: false, callUrl: "http://example.com"});
-
-        TestUtils.findRenderedDOMComponentWithClass(view, "button-email");
-        TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-email"));
-
-        sinon.assert.calledOnce(composeCallUrlEmail);
-        sinon.assert.calledWithExactly(composeCallUrlEmail, "http://example.com");
-      });
-
-      it("should feature a copy button capable of copying the call url when clicked", function() {
-        fakeClient.requestCallUrl = sandbox.stub();
-        var view = TestUtils.renderIntoDocument(
-          React.createElement(loop.panel.CallUrlResult, {
-          notifications: notifications,
-          client: fakeClient
-        }));
-        view.setState({
-          pending: false,
-          copied: false,
-          callUrl: "http://example.com",
-          callUrlExpiry: 6000
-        });
-
-        TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-copy"));
-
-        sinon.assert.calledOnce(navigator.mozLoop.copyString);
-        sinon.assert.calledWithExactly(navigator.mozLoop.copyString,
-          view.state.callUrl);
-      });
-
-      it("should note the call url expiry when the url is copied via button",
-        function() {
-          var view = TestUtils.renderIntoDocument(
-            React.createElement(loop.panel.CallUrlResult, {
-            notifications: notifications,
-            client: fakeClient
-          }));
-          view.setState({
-            pending: false,
-            copied: false,
-            callUrl: "http://example.com",
-            callUrlExpiry: 6000
-          });
-
-          TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-copy"));
-
-          sinon.assert.calledOnce(navigator.mozLoop.noteCallUrlExpiry);
-          sinon.assert.calledWithExactly(navigator.mozLoop.noteCallUrlExpiry,
-            6000);
-        });
-
-      it("should call mozLoop.telemetryAdd when the url is copied via button",
-        function() {
-          var view = TestUtils.renderIntoDocument(
-            React.createElement(loop.panel.CallUrlResult, {
-            notifications: notifications,
-            client: fakeClient
-          }));
-          view.setState({
-            pending: false,
-            copied: false,
-            callUrl: "http://example.com",
-            callUrlExpiry: 6000
-          });
-
-          // Multiple clicks should result in the URL being counted only once.
-          TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-copy"));
-          TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-copy"));
-
-          sinon.assert.calledOnce(navigator.mozLoop.telemetryAdd);
-          sinon.assert.calledWith(navigator.mozLoop.telemetryAdd,
-                                  "LOOP_CLIENT_CALL_URL_SHARED",
-                                  true);
-        });
-
-      it("should note the call url expiry when the url is emailed",
-        function() {
-          var view = TestUtils.renderIntoDocument(
-            React.createElement(loop.panel.CallUrlResult, {
-            notifications: notifications,
-            client: fakeClient
-          }));
-          view.setState({
-            pending: false,
-            copied: false,
-            callUrl: "http://example.com",
-            callUrlExpiry: 6000
-          });
-
-          TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-email"));
-
-          sinon.assert.calledOnce(navigator.mozLoop.noteCallUrlExpiry);
-          sinon.assert.calledWithExactly(navigator.mozLoop.noteCallUrlExpiry,
-            6000);
-        });
-
-      it("should call mozLoop.telemetryAdd when the url is emailed",
-        function() {
-          var view = TestUtils.renderIntoDocument(
-            React.createElement(loop.panel.CallUrlResult, {
-            notifications: notifications,
-            client: fakeClient
-          }));
-          view.setState({
-            pending: false,
-            copied: false,
-            callUrl: "http://example.com",
-            callUrlExpiry: 6000
-          });
-
-          // Multiple clicks should result in the URL being counted only once.
-          TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-email"));
-          TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-email"));
-
-          sinon.assert.calledOnce(navigator.mozLoop.telemetryAdd);
-          sinon.assert.calledWith(navigator.mozLoop.telemetryAdd,
-                                  "LOOP_CLIENT_CALL_URL_SHARED",
-                                  true);
-        });
-
-      it("should note the call url expiry when the url is copied manually",
-        function() {
-          var view = TestUtils.renderIntoDocument(
-            React.createElement(loop.panel.CallUrlResult, {
-            notifications: notifications,
-            client: fakeClient
-          }));
-          view.setState({
-            pending: false,
-            copied: false,
-            callUrl: "http://example.com",
-            callUrlExpiry: 6000
-          });
-
-          var urlField = view.getDOMNode().querySelector("input[type='url']");
-          TestUtils.Simulate.copy(urlField);
-
-          sinon.assert.calledOnce(navigator.mozLoop.noteCallUrlExpiry);
-          sinon.assert.calledWithExactly(navigator.mozLoop.noteCallUrlExpiry,
-            6000);
-        });
-
-      it("should call mozLoop.telemetryAdd when the url is copied manually",
-        function() {
-          var view = TestUtils.renderIntoDocument(
-            React.createElement(loop.panel.CallUrlResult, {
-            notifications: notifications,
-            client: fakeClient
-          }));
-          view.setState({
-            pending: false,
-            copied: false,
-            callUrl: "http://example.com",
-            callUrlExpiry: 6000
-          });
-
-          // Multiple copies should result in the URL being counted only once.
-          var urlField = view.getDOMNode().querySelector("input[type='url']");
-          TestUtils.Simulate.copy(urlField);
-          TestUtils.Simulate.copy(urlField);
-
-          sinon.assert.calledOnce(navigator.mozLoop.telemetryAdd);
-          sinon.assert.calledWith(navigator.mozLoop.telemetryAdd,
-                                  "LOOP_CLIENT_CALL_URL_SHARED",
-                                  true);
-        });
-
-      it("should notify the user when the operation failed", function() {
-        fakeClient.requestCallUrl = function(_, cb) {
-          cb("fake error");
-        };
-        sandbox.stub(notifications, "errorL10n");
-        TestUtils.renderIntoDocument(
-          React.createElement(loop.panel.CallUrlResult, {
-            notifications: notifications,
-            client: fakeClient
-          }));
-
-        sinon.assert.calledOnce(notifications.errorL10n);
-        sinon.assert.calledWithExactly(notifications.errorL10n,
-                                       "unable_retrieve_url");
-      });
-    });
-  });
-
   describe("loop.panel.RoomEntry", function() {
     var dispatcher, roomData;
 
     beforeEach(function() {
       dispatcher = new loop.Dispatcher();
       roomData = {
         roomToken: "QzBbvGmIZWU",
         roomUrl: "http://sample/QzBbvGmIZWU",
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -15,17 +15,19 @@ describe("loop.roomViews", function () {
     dispatcher = new loop.Dispatcher();
 
     fakeWindow = {
       document: {},
       navigator: {
         mozLoop: {
           getAudioBlob: sinon.stub()
         }
-      }
+      },
+      addEventListener: function() {},
+      removeEventListener: function() {}
     };
     loop.shared.mixins.setRootObject(fakeWindow);
 
     // XXX These stubs should be hoisted in a common file
     // Bug 1040968
     sandbox.stub(document.mozL10n, "get", function(x) {
       return x;
     });
--- a/browser/components/loop/test/shared/mixins_test.js
+++ b/browser/components/loop/test/shared/mixins_test.js
@@ -15,16 +15,17 @@ describe("loop.shared.mixins", function(
   var ROOM_STATES = loop.store.ROOM_STATES;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
   });
 
   afterEach(function() {
     sandbox.restore();
+    sharedMixins.setRootObject(window);
   });
 
   describe("loop.shared.mixins.UrlHashChangeMixin", function() {
     function createTestComponent(onUrlHashChange) {
       var TestComp = React.createClass({
         mixins: [loop.shared.mixins.UrlHashChangeMixin],
         onUrlHashChange: onUrlHashChange || function(){},
         render: function() {
@@ -157,20 +158,16 @@ describe("loop.shared.mixins", function(
         onDocumentHidden: onDocumentHiddenStub,
         onDocumentVisible: onDocumentVisibleStub,
         render: function() {
           return React.DOM.div();
         }
       });
     });
 
-    afterEach(function() {
-      loop.shared.mixins.setRootObject(window);
-    });
-
     function setupFakeVisibilityEventDispatcher(event) {
       loop.shared.mixins.setRootObject({
         document: {
           addEventListener: function(_, fn) {
             fn(event);
           },
           removeEventListener: sandbox.stub()
         }
@@ -191,16 +188,110 @@ describe("loop.shared.mixins", function(
         setupFakeVisibilityEventDispatcher({target: {hidden: true}});
 
         comp = TestUtils.renderIntoDocument(React.createElement(TestComp));
 
         sinon.assert.calledOnce(onDocumentHiddenStub);
       });
   });
 
+  describe("loop.shared.mixins.MediaSetupMixin", function() {
+    var view, TestComp, rootObject;
+
+    beforeEach(function() {
+      TestComp = React.createClass({
+        mixins: [loop.shared.mixins.MediaSetupMixin],
+        render: function() {
+          return React.DOM.div();
+        }
+      });
+
+      rootObject = {
+        events: {},
+        addEventListener: function(eventName, listener) {
+          this.events[eventName] = listener;
+        },
+        removeEventListener: function(eventName) {
+          delete this.events[eventName];
+        }
+      };
+
+      sharedMixins.setRootObject(rootObject);
+
+      view = TestUtils.renderIntoDocument(React.createElement(TestComp));
+    });
+
+    describe("#getDefaultPublisherConfig", function() {
+      it("should provide a default publisher configuration", function() {
+        var defaultConfig = view.getDefaultPublisherConfig({publishVideo: true});
+
+        expect(defaultConfig.publishVideo).eql(true);
+      });
+    });
+
+    describe("Events", function() {
+      var localElement, remoteElement;
+
+      beforeEach(function() {
+        sandbox.stub(view, "getDOMNode").returns({
+          querySelector: function(classSelector) {
+            if (classSelector.contains("local")) {
+              return localElement;
+            }
+            return remoteElement;
+          }
+        });
+      });
+
+      describe("resize", function() {
+        it("should update the width on the local stream element", function() {
+          localElement = {
+            style: { width: "0%" }
+          };
+
+          rootObject.events.resize();
+
+          expect(localElement.style.width).eql("100%");
+        });
+
+        it("should update the height on the remote stream element", function() {
+          remoteElement = {
+            style: { height: "0%" }
+          };
+
+          rootObject.events.resize();
+
+          expect(remoteElement.style.height).eql("100%");
+        });
+      });
+
+      describe("orientationchange", function() {
+        it("should update the width on the local stream element", function() {
+          localElement = {
+            style: { width: "0%" }
+          };
+
+          rootObject.events.orientationchange();
+
+          expect(localElement.style.width).eql("100%");
+        });
+
+        it("should update the height on the remote stream element", function() {
+          remoteElement = {
+            style: { height: "0%" }
+          };
+
+          rootObject.events.orientationchange();
+
+          expect(remoteElement.style.height).eql("100%");
+        });
+      });
+    });
+  });
+
   describe("loop.shared.mixins.AudioMixin", function() {
     var view, fakeAudio, TestComp;
 
     beforeEach(function() {
       navigator.mozLoop = {
         doNotDisturb: true,
         getAudioBlob: sinon.spy(function(name, callback) {
           callback(null, new Blob([new ArrayBuffer(10)], {type: 'audio/ogg'}));
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -260,28 +260,16 @@ describe("loop.shared.views", function()
           initiate: false,
           sdk: fakeSDK,
           model: model,
           video: {enabled: true}
         });
 
         sinon.assert.notCalled(model.startSession);
       });
-
-      it("should set the correct stream publish options", function() {
-
-        var component = mountTestComponent({
-          sdk: fakeSDK,
-          model: model,
-          video: {enabled: false}
-        });
-
-        expect(component.publisherConfig.publishVideo).to.eql(false);
-
-      });
     });
 
     describe("constructed", function() {
       var comp;
 
       beforeEach(function() {
         comp = mountTestComponent({
           sdk: fakeSDK,
deleted file mode 100644
--- a/browser/components/loop/test/xpcshell/test_loopservice_expiry.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-function expiryTimePref() {
-  return Services.prefs.getIntPref("loop.urlsExpiryTimeSeconds");
-}
-
-function run_test()
-{
-  setupFakeLoopServer();
-
-  Services.prefs.setIntPref("loop.urlsExpiryTimeSeconds", 0);
-
-  MozLoopService.noteCallUrlExpiry(1000);
-
-  Assert.equal(expiryTimePref(), 1000, "should be equal to set value");
-
-  MozLoopService.noteCallUrlExpiry(900);
-
-  Assert.equal(expiryTimePref(), 1000, "should remain the same value");
-
-  MozLoopService.noteCallUrlExpiry(1500);
-
-  Assert.equal(expiryTimePref(), 1500, "should be the increased value");
-}
--- a/browser/components/loop/test/xpcshell/test_loopservice_locales.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_locales.js
@@ -11,18 +11,18 @@ function test_locale() {
 }
 
 function test_getStrings() {
   // Try an invalid string
   Assert.equal(MozLoopService.getStrings("invalid_not_found_string"), "");
 
   // XXX This depends on the L10n values, which I'd prefer not to do, but is the
   // simplest way for now.
-  Assert.equal(MozLoopService.getStrings("share_link_header_text"),
-               '{"textContent":"Share this link to invite someone to talk:"}');
+  Assert.equal(MozLoopService.getStrings("display_name_guest"),
+               '{"textContent":"Guest"}');
 }
 
 function run_test()
 {
   setupFakeLoopServer();
 
   test_locale();
   test_getStrings();
--- a/browser/components/loop/test/xpcshell/xpcshell.ini
+++ b/browser/components/loop/test/xpcshell/xpcshell.ini
@@ -4,17 +4,16 @@ tail =
 firefox-appdir = browser
 skip-if = toolkit == 'gonk'
 
 [test_loopapi_hawk_request.js]
 [test_looppush_initialize.js]
 [test_looprooms.js]
 [test_loopservice_directcall.js]
 [test_loopservice_dnd.js]
-[test_loopservice_expiry.js]
 [test_loopservice_hawk_errors.js]
 [test_loopservice_hawk_request.js]
 [test_loopservice_loop_prefs.js]
 [test_loopservice_initialize.js]
 [test_loopservice_locales.js]
 [test_loopservice_notification.js]
 [test_loopservice_registration.js]
 [test_loopservice_registration_retry.js]
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -43,24 +43,20 @@ var fakeRooms = [
   }
 ];
 
 /**
  * Faking the mozLoop object which doesn't exist in regular web pages.
  * @type {Object}
  */
 navigator.mozLoop = {
-  roomsEnabled: false,
   ensureRegistered: function() {},
   getAudioBlob: function(){},
   getLoopPref: function(pref) {
     switch(pref) {
-      // Ensure UI for rooms is displayed in the showcase.
-      case "rooms.enabled":
-        return this.roomsEnabled;
       // Ensure we skip FTE completely.
       case "gettingStarted.seen":
         return true;
     }
   },
   setLoopPref: function(){},
   releaseCallData: function() {},
   copyString: function() {},
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -78,27 +78,25 @@
     client: {},
     mozLoop: navigator.mozLoop,
     sdkDriver: {}
   });
 
   // Local mocks
 
   var mockMozLoopRooms = _.extend({}, navigator.mozLoop);
-  mockMozLoopRooms.roomsEnabled = true;
 
   var mockContact = {
     name: ["Mr Smith"],
     email: [{
       value: "smith@invalid.com"
     }]
   };
 
   var mockClient = {
-    requestCallUrl: noop,
     requestCallUrlInfo: noop
   };
 
   var mockSDK = {};
 
   var mockConversationModel = new loop.shared.models.ConversationModel({
     callerId: "Mrs Jones",
     urlCreationDate: (new Date() / 1000).toString()
@@ -215,64 +213,60 @@
   var App = React.createClass({displayName: "App",
     render: function() {
       return (
         React.createElement(ShowCase, null, 
           React.createElement(Section, {name: "PanelView"}, 
             React.createElement("p", {className: "note"}, 
               React.createElement("strong", null, "Note:"), " 332px wide."
             ), 
-            React.createElement(Example, {summary: "Call URL retrieved", dashed: "true", style: {width: "332px"}}, 
-              React.createElement(PanelView, {client: mockClient, notifications: notifications, 
-                         callUrl: "http://invalid.example.url/", 
-                         mozLoop: navigator.mozLoop, 
-                         dispatcher: dispatcher, 
-                         roomStore: roomStore})
-            ), 
-            React.createElement(Example, {summary: "Call URL retrieved - authenticated", dashed: "true", style: {width: "332px"}}, 
-              React.createElement(PanelView, {client: mockClient, notifications: notifications, 
-                         callUrl: "http://invalid.example.url/", 
-                         userProfile: {email: "test@example.com"}, 
-                         mozLoop: navigator.mozLoop, 
-                         dispatcher: dispatcher, 
-                         roomStore: roomStore})
-            ), 
-            React.createElement(Example, {summary: "Pending call url retrieval", dashed: "true", style: {width: "332px"}}, 
-              React.createElement(PanelView, {client: mockClient, notifications: notifications, 
-                         mozLoop: navigator.mozLoop, 
-                         dispatcher: dispatcher, 
-                         roomStore: roomStore})
-            ), 
-            React.createElement(Example, {summary: "Pending call url retrieval - authenticated", dashed: "true", style: {width: "332px"}}, 
+            React.createElement(Example, {summary: "Room list tab", dashed: "true", style: {width: "332px"}}, 
               React.createElement(PanelView, {client: mockClient, notifications: notifications, 
                          userProfile: {email: "test@example.com"}, 
-                         mozLoop: navigator.mozLoop, 
+                         mozLoop: mockMozLoopRooms, 
                          dispatcher: dispatcher, 
-                         roomStore: roomStore})
+                         roomStore: roomStore, 
+                         selectedTab: "rooms"})
+            ), 
+            React.createElement(Example, {summary: "Contact list tab", dashed: "true", style: {width: "332px"}}, 
+              React.createElement(PanelView, {client: mockClient, notifications: notifications, 
+                         userProfile: {email: "test@example.com"}, 
+                         mozLoop: mockMozLoopRooms, 
+                         dispatcher: dispatcher, 
+                         roomStore: roomStore, 
+                         selectedTab: "contacts"})
             ), 
             React.createElement(Example, {summary: "Error Notification", dashed: "true", style: {width: "332px"}}, 
               React.createElement(PanelView, {client: mockClient, notifications: errNotifications, 
                          mozLoop: navigator.mozLoop, 
                          dispatcher: dispatcher, 
                          roomStore: roomStore})
             ), 
             React.createElement(Example, {summary: "Error Notification - authenticated", dashed: "true", style: {width: "332px"}}, 
               React.createElement(PanelView, {client: mockClient, notifications: errNotifications, 
                          userProfile: {email: "test@example.com"}, 
                          mozLoop: navigator.mozLoop, 
                          dispatcher: dispatcher, 
                          roomStore: roomStore})
             ), 
-            React.createElement(Example, {summary: "Room list tab", dashed: "true", style: {width: "332px"}}, 
-              React.createElement(PanelView, {client: mockClient, notifications: notifications, 
+            React.createElement(Example, {summary: "Contact import success", dashed: "true", style: {width: "332px"}}, 
+              React.createElement(PanelView, {notifications: new loop.shared.models.NotificationCollection([{level: "success", message: "Import success"}]), 
                          userProfile: {email: "test@example.com"}, 
                          mozLoop: mockMozLoopRooms, 
                          dispatcher: dispatcher, 
                          roomStore: roomStore, 
-                         selectedTab: "rooms"})
+                         selectedTab: "contacts"})
+            ), 
+            React.createElement(Example, {summary: "Contact import error", dashed: "true", style: {width: "332px"}}, 
+              React.createElement(PanelView, {notifications: new loop.shared.models.NotificationCollection([{level: "error", message: "Import error"}]), 
+                         userProfile: {email: "test@example.com"}, 
+                         mozLoop: mockMozLoopRooms, 
+                         dispatcher: dispatcher, 
+                         roomStore: roomStore, 
+                         selectedTab: "contacts"})
             )
           ), 
 
           React.createElement(Section, {name: "IncomingCallView"}, 
             React.createElement(Example, {summary: "Default / incoming video call", dashed: "true", style: {width: "260px", height: "254px"}}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(IncomingCallView, {model: mockConversationModel, 
                                   video: true})
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -78,27 +78,25 @@
     client: {},
     mozLoop: navigator.mozLoop,
     sdkDriver: {}
   });
 
   // Local mocks
 
   var mockMozLoopRooms = _.extend({}, navigator.mozLoop);
-  mockMozLoopRooms.roomsEnabled = true;
 
   var mockContact = {
     name: ["Mr Smith"],
     email: [{
       value: "smith@invalid.com"
     }]
   };
 
   var mockClient = {
-    requestCallUrl: noop,
     requestCallUrlInfo: noop
   };
 
   var mockSDK = {};
 
   var mockConversationModel = new loop.shared.models.ConversationModel({
     callerId: "Mrs Jones",
     urlCreationDate: (new Date() / 1000).toString()
@@ -215,64 +213,60 @@
   var App = React.createClass({
     render: function() {
       return (
         <ShowCase>
           <Section name="PanelView">
             <p className="note">
               <strong>Note:</strong> 332px wide.
             </p>
-            <Example summary="Call URL retrieved" dashed="true" style={{width: "332px"}}>
-              <PanelView client={mockClient} notifications={notifications}
-                         callUrl="http://invalid.example.url/"
-                         mozLoop={navigator.mozLoop}
-                         dispatcher={dispatcher}
-                         roomStore={roomStore} />
-            </Example>
-            <Example summary="Call URL retrieved - authenticated" dashed="true" style={{width: "332px"}}>
-              <PanelView client={mockClient} notifications={notifications}
-                         callUrl="http://invalid.example.url/"
-                         userProfile={{email: "test@example.com"}}
-                         mozLoop={navigator.mozLoop}
-                         dispatcher={dispatcher}
-                         roomStore={roomStore} />
-            </Example>
-            <Example summary="Pending call url retrieval" dashed="true" style={{width: "332px"}}>
-              <PanelView client={mockClient} notifications={notifications}
-                         mozLoop={navigator.mozLoop}
-                         dispatcher={dispatcher}
-                         roomStore={roomStore} />
-            </Example>
-            <Example summary="Pending call url retrieval - authenticated" dashed="true" style={{width: "332px"}}>
+            <Example summary="Room list tab" dashed="true" style={{width: "332px"}}>
               <PanelView client={mockClient} notifications={notifications}
                          userProfile={{email: "test@example.com"}}
-                         mozLoop={navigator.mozLoop}
+                         mozLoop={mockMozLoopRooms}
                          dispatcher={dispatcher}
-                         roomStore={roomStore} />
+                         roomStore={roomStore}
+                         selectedTab="rooms" />
+            </Example>
+            <Example summary="Contact list tab" dashed="true" style={{width: "332px"}}>
+              <PanelView client={mockClient} notifications={notifications}
+                         userProfile={{email: "test@example.com"}}
+                         mozLoop={mockMozLoopRooms}
+                         dispatcher={dispatcher}
+                         roomStore={roomStore}
+                         selectedTab="contacts" />
             </Example>
             <Example summary="Error Notification" dashed="true" style={{width: "332px"}}>
               <PanelView client={mockClient} notifications={errNotifications}
                          mozLoop={navigator.mozLoop}
                          dispatcher={dispatcher}
                          roomStore={roomStore} />
             </Example>
             <Example summary="Error Notification - authenticated" dashed="true" style={{width: "332px"}}>
               <PanelView client={mockClient} notifications={errNotifications}
                          userProfile={{email: "test@example.com"}}
                          mozLoop={navigator.mozLoop}
                          dispatcher={dispatcher}
                          roomStore={roomStore} />
             </Example>
-            <Example summary="Room list tab" dashed="true" style={{width: "332px"}}>
-              <PanelView client={mockClient} notifications={notifications}
+            <Example summary="Contact import success" dashed="true" style={{width: "332px"}}>
+              <PanelView notifications={new loop.shared.models.NotificationCollection([{level: "success", message: "Import success"}])}
                          userProfile={{email: "test@example.com"}}
                          mozLoop={mockMozLoopRooms}
                          dispatcher={dispatcher}
                          roomStore={roomStore}
-                         selectedTab="rooms" />
+                         selectedTab="contacts" />
+            </Example>
+            <Example summary="Contact import error" dashed="true" style={{width: "332px"}}>
+              <PanelView notifications={new loop.shared.models.NotificationCollection([{level: "error", message: "Import error"}])}
+                         userProfile={{email: "test@example.com"}}
+                         mozLoop={mockMozLoopRooms}
+                         dispatcher={dispatcher}
+                         roomStore={roomStore}
+                         selectedTab="contacts" />
             </Example>
           </Section>
 
           <Section name="IncomingCallView">
             <Example summary="Default / incoming video call" dashed="true" style={{width: "260px", height: "254px"}}>
               <div className="fx-embedded">
                 <IncomingCallView model={mockConversationModel}
                                   video={true} />
--- a/browser/components/preferences/in-content/main.js
+++ b/browser/components/preferences/in-content/main.js
@@ -173,17 +173,20 @@ var gMainPane = {
       Services.obs.notifyObservers(cancelQuit, "quit-application-requested",
                                    "restart");
       shouldProceed = !cancelQuit.data;
 
       if (shouldProceed) {
         for (let prefToChange of prefsToChange) {
           prefToChange.value = e10sCheckbox.checked;
         }
-        if (!e10sCheckbox.checked) {
+
+        let tmp = {};
+        Components.utils.import("resource://gre/modules/UpdateChannel.jsm", tmp);
+        if (!e10sCheckbox.checked && tmp.UpdateChannel.get() == "nightly") {
           Services.prefs.setBoolPref("browser.requestE10sFeedback", true);
           Services.prompt.alert(window, brandName, "After restart, a tab will open to input.mozilla.org where you can provide us feedback about your e10s experience.");
         }
         Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit |  Ci.nsIAppStartup.eRestart);
       }
     }
 
     // Revert the checkbox in case we didn't quit
--- a/browser/components/search/content/search.xml
+++ b/browser/components/search/content/search.xml
@@ -500,25 +500,25 @@
 #endif
               where = "tab-background";
           }
 
           let selection = this.telemetrySearchDetails;
           this.doSearch(textValue, where, aEngine);
 
           if (!selection || (selection.index == -1)) {
-            let target = aEvent.originalTarget;
             let source = "unknown";
             let type = "unknown";
             if (aEvent instanceof KeyboardEvent) {
               type = "key";
               if (this._textbox.getSelectedOneOff()) {
                 source = "oneoff";
               }
             } else if (aEvent instanceof MouseEvent) {
+              let target = aEvent.originalTarget;
               type = "mouse";
               if (target.classList.contains("searchbar-engine-one-off-item")) {
                 source = "oneoff";
               } else if (target.classList.contains("search-panel-header") ||
                          target.parentNode.classList.contains("search-panel-header")) {
                 source = "header";
               }
             }
--- a/browser/components/sessionstore/SessionCookies.jsm
+++ b/browser/components/sessionstore/SessionCookies.jsm
@@ -256,16 +256,50 @@ let SessionCookiesInternal = {
     let iter = Services.cookies.enumerator;
     while (iter.hasMoreElements()) {
       this._updateCookie(iter.getNext());
     }
   }
 };
 
 /**
+ * Generates all possible subdomains for a given host and prepends a leading
+ * dot to all variants.
+ *
+ * See http://tools.ietf.org/html/rfc6265#section-5.1.3
+ *     http://en.wikipedia.org/wiki/HTTP_cookie#Domain_and_Path
+ *
+ * All cookies belonging to a web page will be internally represented by a
+ * nsICookie object. nsICookie.host will be the request host if no domain
+ * parameter was given when setting the cookie. If a specific domain was given
+ * then nsICookie.host will contain that specific domain and prepend a leading
+ * dot to it.
+ *
+ * We thus generate all possible subdomains for a given domain and prepend a
+ * leading dot to them as that is the value that was used as the map key when
+ * the cookie was set.
+ */
+function* getPossibleSubdomainVariants(host) {
+  // Try given domain with a leading dot (.www.example.com).
+  yield "." + host;
+
+  // Stop if there are only two parts left (e.g. example.com was given).
+  let parts = host.split(".");
+  if (parts.length < 3) {
+    return;
+  }
+
+  // Remove the first subdomain (www.example.com -> example.com).
+  let rest = parts.slice(1).join(".");
+
+  // Try possible parent subdomains.
+  yield* getPossibleSubdomainVariants(rest);
+}
+
+/**
  * The internal cookie storage that keeps track of every active session cookie.
  * These are stored using maps per host, path, and cookie name.
  */
 let CookieStore = {
   /**
    * The internal structure holding all known cookies.
    *
    * Host =>
@@ -280,36 +314,54 @@ let CookieStore = {
    *       "username": {name: "username", value: "my_name_is", etc...},
    *       "sessionid": {name: "sessionid", value: "1fdb3a", etc...}
    *     }
    *   },
    *   "tbpl.mozilla.org": {
    *     "/path": {
    *       "cookiename": {name: "cookiename", value: "value", etc...}
    *     }
+   *   },
+   *   ".example.com": {
+   *     "/path": {
+   *       "cookiename": {name: "cookiename", value: "value", etc...}
+   *     }
    *   }
    * };
    */
   _hosts: new Map(),
 
   /**
    * Returns the list of stored session cookies for a given host.
    *
    * @param host
    *        A string containing the host name we want to get cookies for.
    */
   getCookiesForHost: function (host) {
-    if (!this._hosts.has(host)) {
-      return [];
+    let cookies = [];
+
+    let appendCookiesForHost = host => {
+      if (!this._hosts.has(host)) {
+        return;
+      }
+
+      for (let pathToNamesMap of this._hosts.get(host).values()) {
+        cookies.push(...pathToNamesMap.values());
+      }
     }
 
-    let cookies = [];
-
-    for (let pathToNamesMap of this._hosts.get(host).values()) {
-      cookies.push(...pathToNamesMap.values());
+    // Try to find cookies for the given host, e.g. <www.example.com>.
+    // The full hostname will be in the map if the Set-Cookie header did not
+    // have a domain= attribute, i.e. the cookie will only be stored for the
+    // request domain. Also, try to find cookies for subdomains, e.g.
+    // <.example.com>. We will find those variants with a leading dot in the
+    // map if the Set-Cookie header had a domain= attribute, i.e. the cookie
+    // will be stored for a parent domain and we send it for any subdomain.
+    for (let variant of [host, ...getPossibleSubdomainVariants(host)]) {
+      appendCookiesForHost(variant);
     }
 
     return cookies;
   },
 
   /**
    * Stores a given cookie.
    *
--- a/browser/components/sessionstore/SessionStore.jsm
+++ b/browser/components/sessionstore/SessionStore.jsm
@@ -761,17 +761,17 @@ let SessionStoreInternal = {
         this.onTabHide(win, aEvent.originalTarget);
         break;
       case "TabPinned":
       case "TabUnpinned":
       case "SwapDocShells":
         this.saveStateDelayed(win);
         break;
       case "oop-browser-crashed":
-        this._crashedBrowsers.add(aEvent.originalTarget.permanentKey);
+        this.onBrowserCrashed(win, aEvent.originalTarget);
         break;
     }
     this._clearRestoringWindows();
   },
 
   /**
    * Generate a unique window identifier
    * @return string
@@ -1456,16 +1456,39 @@ let SessionStoreInternal = {
       TabRestoreQueue.visibleToHidden(aTab);
     }
 
     // Default delay of 2 seconds gives enough time to catch multiple TabHide
     // events due to changing groups in Panorama.
     this.saveStateDelayed(aWindow);
   },
 
+  /**
+   * Handler for the event that is fired when a <xul:browser> crashes.
+   *
+   * @param aWindow
+   *        The window that the crashed browser belongs to.
+   * @param aBrowser
+   *        The <xul:browser> that is now in the crashed state.
+   */
+  onBrowserCrashed: function(aWindow, aBrowser) {
+    this._crashedBrowsers.add(aBrowser.permanentKey);
+    // If we never got around to restoring this tab, clear its state so
+    // that we don't try restoring if the user switches to it before
+    // reviving the crashed browser. This is throwing away the information
+    // that the tab was in a pending state when the browser crashed, which
+    // is an explicit choice. For now, when restoring all crashed tabs, based
+    // on a user preference we'll either restore all of them at once, or only
+    // restore the selected tab and lazily restore the rest. We'll make no
+    // efforts at this time to be smart and restore all of the tabs that had
+    // been in a restored state at the time of the crash.
+    let tab = aWindow.gBrowser.getTabForBrowser(aBrowser);
+    this._resetLocalTabRestoringState(tab);
+  },
+
   onGatherTelemetry: function() {
     // On the first gather-telemetry notification of the session,
     // gather telemetry data.
     Services.obs.removeObserver(this, "gather-telemetry");
     let stateString = SessionStore.getBrowserState();
     return SessionFile.gatherTelemetry(stateString);
   },
 
--- a/browser/components/sessionstore/test/browser.ini
+++ b/browser/components/sessionstore/test/browser.ini
@@ -7,16 +7,17 @@
 # browser_589246.js is disabled for leaking browser windows (bug 752467)
 # browser_580512.js is disabled for leaking browser windows (bug 752467)
 
 [DEFAULT]
 support-files =
   head.js
   content.js
   content-forms.js
+  browser_cookies.sjs
   browser_formdata_sample.html
   browser_formdata_xpath_sample.html
   browser_frametree_sample.html
   browser_frametree_sample_frameset.html
   browser_frame_history_index.html
   browser_frame_history_index2.html
   browser_frame_history_index_blank.html
   browser_frame_history_a.html
@@ -60,16 +61,17 @@ support-files =
 
 [browser_aboutPrivateBrowsing.js]
 [browser_aboutSessionRestore.js]
 [browser_attributes.js]
 [browser_backup_recovery.js]
 [browser_broadcast.js]
 [browser_capabilities.js]
 [browser_cleaner.js]
+[browser_cookies.js]
 [browser_crashedTabs.js]
 skip-if = !e10s || os == "linux" # Waiting on OMTC enabled by default on Linux (Bug 994541)
 [browser_dying_cache.js]
 [browser_dynamic_frames.js]
 [browser_form_restore_events.js]
 [browser_formdata.js]
 skip-if = buildapp == 'mulet'
 [browser_formdata_format.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_cookies.js
@@ -0,0 +1,175 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const PATH = "/browser/browser/components/sessionstore/test/";
+
+/**
+ * Remove all cookies to start off a clean slate.
+ */
+add_task(function* test_setup() {
+  Services.cookies.removeAll();
+});
+
+/**
+ * Test multiple scenarios with different Set-Cookie header domain= params.
+ */
+add_task(function* test_run() {
+  // Set-Cookie: foobar=random()
+  // The domain of the cookie should be the request domain (www.example.com).
+  // We should collect data only for the request domain, no parent or subdomains.
+  yield testCookieCollection({
+    host: "http://www.example.com",
+    cookieHost: "www.example.com",
+    cookieURIs: ["http://www.example.com" + PATH],
+    noCookieURIs: ["http://example.com/" + PATH]
+  });
+
+  // Set-Cookie: foobar=random()
+  // The domain of the cookie should be the request domain (example.com).
+  // We should collect data only for the request domain, no parent or subdomains.
+  yield testCookieCollection({
+    host: "http://example.com",
+    cookieHost: "example.com",
+    cookieURIs: ["http://example.com" + PATH],
+    noCookieURIs: ["http://www.example.com/" + PATH]
+  });
+
+  // Set-Cookie: foobar=random(); Domain=example.com
+  // The domain of the cookie should be the given one (.example.com).
+  // We should collect data for the given domain and its subdomains.
+  yield testCookieCollection({
+    host: "http://example.com",
+    domain: "example.com",
+    cookieHost: ".example.com",
+    cookieURIs: ["http://example.com" + PATH, "http://www.example.com/" + PATH],
+    noCookieURIs: ["about:robots"]
+  });
+
+  // Set-Cookie: foobar=random(); Domain=.example.com
+  // The domain of the cookie should be the given one (.example.com).
+  // We should collect data for the given domain and its subdomains.
+  yield testCookieCollection({
+    host: "http://example.com",
+    domain: ".example.com",
+    cookieHost: ".example.com",
+    cookieURIs: ["http://example.com" + PATH, "http://www.example.com/" + PATH],
+    noCookieURIs: ["about:robots"]
+  });
+
+  // Set-Cookie: foobar=random(); Domain=www.example.com
+  // The domain of the cookie should be the given one (.www.example.com).
+  // We should collect data for the given domain and its subdomains.
+  yield testCookieCollection({
+    host: "http://www.example.com",
+    domain: "www.example.com",
+    cookieHost: ".www.example.com",
+    cookieURIs: ["http://www.example.com/" + PATH],
+    noCookieURIs: ["http://example.com"]
+  });
+
+  // Set-Cookie: foobar=random(); Domain=.www.example.com
+  // The domain of the cookie should be the given one (.www.example.com).
+  // We should collect data for the given domain and its subdomains.
+  yield testCookieCollection({
+    host: "http://www.example.com",
+    domain: ".www.example.com",
+    cookieHost: ".www.example.com",
+    cookieURIs: ["http://www.example.com/" + PATH],
+    noCookieURIs: ["http://example.com"]
+  });
+});
+
+/**
+ * Generic test function to check sessionstore's cookie collection module with
+ * different cookie domains given in the Set-Cookie header. See above for some
+ * usage examples.
+ */
+let testCookieCollection = Task.async(function (params) {
+  let tab = gBrowser.addTab("about:blank");
+  let browser = tab.linkedBrowser;
+
+  let urlParams = new URLSearchParams();
+  let value = Math.random();
+  urlParams.append("value", value);
+
+  if (params.domain) {
+    urlParams.append("domain", params.domain);
+  }
+
+  // Construct request URI.
+  let uri = `${params.host}${PATH}browser_cookies.sjs?${urlParams}`;
+
+  // Wait for the browser to load and the cookie to be set.
+  // These two events can probably happen in no particular order,
+  // so let's wait for them in parallel.
+  yield Promise.all([
+    waitForNewCookie(),
+    replaceCurrentURI(browser, uri)
+  ]);
+
+  // Check all URIs for which the cookie should be collected.
+  for (let uri of params.cookieURIs || []) {
+    yield replaceCurrentURI(browser, uri);
+
+    // Check the cookie.
+    let cookie = getCookie();
+    is(cookie.host, params.cookieHost, "cookie host is correct");
+    is(cookie.path, PATH, "cookie path is correct");
+    is(cookie.name, "foobar", "cookie name is correct");
+    is(cookie.value, value, "cookie value is correct");
+  }
+
+  // Check all URIs for which the cookie should NOT be collected.
+  for (let uri of params.noCookieURIs || []) {
+    yield replaceCurrentURI(browser, uri);
+
+    // Cookie should be ignored.
+    ok(!getCookie(), "no cookie collected");
+  }
+
+  // Clean up.
+  gBrowser.removeTab(tab);
+  Services.cookies.removeAll();
+});
+
+/**
+ * Replace the current URI of the given browser by loading a new URI. The
+ * browser's session history will be completely replaced. This function ensures
+ * that the parent process has the lastest shistory data before resolving.
+ */
+let replaceCurrentURI = Task.async(function* (browser, uri) {
+  // Replace the tab's current URI with the parent domain.
+  let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY;
+  browser.loadURIWithFlags(uri, flags);
+  yield promiseBrowserLoaded(browser);
+
+  // Ensure the tab's session history is up-to-date.
+  TabState.flush(browser);
+});
+
+/**
+ * Waits for a new "*example.com" cookie to be added.
+ */
+function waitForNewCookie() {
+  return new Promise(resolve => {
+    Services.obs.addObserver(function observer(subj, topic, data) {
+      let cookie = subj.QueryInterface(Ci.nsICookie2);
+      if (data == "added" && cookie.host.endsWith("example.com")) {
+        Services.obs.removeObserver(observer, topic);
+        resolve();
+      }
+    }, "cookie-changed", false);
+  });
+}
+
+/**
+ * Retrieves the first cookie in the first window from the current sessionstore
+ * state.
+ */
+function getCookie() {
+  let state = JSON.parse(ss.getWindowState(window));
+  let cookies = state.windows[0].cookies || [];
+  return cookies[0] || null;
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_cookies.sjs
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Components.utils.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(req, resp) {
+  resp.setStatusLine(req.httpVersion, 200);
+
+  let params = new URLSearchParams(req.queryString);
+  let value = params.get("value");
+
+  let domain = "";
+  if  (params.has("domain")) {
+    domain = `; Domain=${params.get("domain")}`;
+  }
+
+  resp.setHeader("Set-Cookie", `foobar=${value}${domain}`);
+  resp.write("<meta charset=utf-8>hi");
+}
--- a/browser/devtools/debugger/test/browser_dbg_optimized-out-vars.js
+++ b/browser/devtools/debugger/test/browser_dbg_optimized-out-vars.js
@@ -22,20 +22,20 @@ function test() {
     sendMouseClickToTab(tab, content.document.querySelector("button"));
 
     yield waitForDebuggerEvents(panel, gDebugger.EVENTS.FETCHED_SCOPES);
     let gVars = gDebugger.DebuggerView.Variables;
     let outerScope = gVars.getScopeAtIndex(1);
     outerScope.expand();
 
     let upvarVar = outerScope.get("upvar");
-    ok(!upvarVar, "upvar was optimized out.");
-    if (upvarVar) {
-      ok(false, "upvar = " + upvarVar.target.querySelector(".value").getAttribute("value"));
-    }
+    ok(upvarVar, "The variable `upvar` is shown.");
+    is(upvarVar.target.querySelector(".value").getAttribute("value"),
+       gDebugger.L10N.getStr('variablesViewOptimizedOut'),
+       "Should show the optimized out message for upvar.");
 
     let argVar = outerScope.get("arg");
     is(argVar.target.querySelector(".name").getAttribute("value"), "arg",
       "Should have the right property name for |arg|.");
     is(argVar.target.querySelector(".value").getAttribute("value"), 42,
       "Should have the right property value for |arg|.");
 
     yield resumeDebuggerThenCloseAndFinish(panel);
--- a/browser/devtools/framework/sidebar.js
+++ b/browser/devtools/framework/sidebar.js
@@ -322,27 +322,45 @@ ToolSidebar.prototype = {
       panel.remove();
     }
 
     this._tabs.delete(tabId);
     this.emit("tab-unregistered", tabId);
   }),
 
   /**
-   * Show or hide a specific tab
+   * Show or hide a specific tab and tabpanel.
+   * @param {Boolean} isVisible True to show the tab/tabpanel, False to hide it.
+   * @param {String} id The ID of the tab to be hidden.
+   * @param {String} tabPanelId Optionally pass the ID for the tabPanel if it
+   * can't be retrieved using the tab ID. This is useful when tabs and tabpanels
+   * existed before the widget was created.
    */
-  toggleTab: function(id, isVisible) {
+  toggleTab: function(isVisible, id, tabPanelId) {
+    // Toggle the tab.
     let tab = this.getTab(id);
     if (!tab) {
       return;
     }
     tab.hidden = !isVisible;
+
+    // Toggle the item in the allTabs menu.
     if (this._allTabsBtn) {
       this._allTabsBtn.querySelector("#sidebar-alltabs-item-" + id).hidden = !isVisible;
     }
+
+    // Toggle the corresponding tabPanel, if one can be found either with the id
+    // or the provided tabPanelId.
+    let tabPanel = this.getTabPanel(id);
+    if (!tabPanel && tabPanelId) {
+      tabPanel = this.getTabPanel(tabPanelId);
+    }
+    if (tabPanel) {
+      tabPanel.hidden = !isVisible;
+    }
   },
 
   /**
    * Select a specific tab.
    */
   select: function(id) {
     let tab = this.getTab(id);
     if (tab) {
--- a/browser/devtools/inspector/inspector-panel.js
+++ b/browser/devtools/inspector/inspector-panel.js
@@ -308,17 +308,19 @@ InspectorPanel.prototype = {
     this.searchSuggestions = new SelectorSearch(this, this.searchBox);
   },
 
   /**
    * Build the sidebar.
    */
   setupSidebar: function InspectorPanel_setupSidebar() {
     let tabbox = this.panelDoc.querySelector("#inspector-sidebar");
-    this.sidebar = new ToolSidebar(tabbox, this, "inspector");
+    this.sidebar = new ToolSidebar(tabbox, this, "inspector", {
+      showAllTabsMenu: true
+    });
 
     let defaultTab = Services.prefs.getCharPref("devtools.inspector.activeSidebar");
 
     this._setDefaultSidebar = (event, toolId) => {
       Services.prefs.setCharPref("devtools.inspector.activeSidebar", toolId);
     };
 
     this.sidebar.on("select", this._setDefaultSidebar);
--- a/browser/devtools/netmonitor/netmonitor-controller.js
+++ b/browser/devtools/netmonitor/netmonitor-controller.js
@@ -114,16 +114,17 @@ Cu.import("resource:///modules/devtools/
 Cu.import("resource:///modules/devtools/VariablesViewController.jsm");
 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
 
 const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
 const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
 const EventEmitter = require("devtools/toolkit/event-emitter");
 const Editor = require("devtools/sourceeditor/editor");
 const {Tooltip} = require("devtools/shared/widgets/Tooltip");
+const {ToolSidebar} = require("devtools/framework/sidebar");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Chart",
   "resource:///modules/devtools/Chart.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Curl",
   "resource:///modules/devtools/Curl.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "CurlUtils",
--- a/browser/devtools/netmonitor/netmonitor-view.js
+++ b/browser/devtools/netmonitor/netmonitor-view.js
@@ -1996,16 +1996,19 @@ CustomRequestView.prototype = {
 }
 
 /**
  * Functions handling the requests details view.
  */
 function NetworkDetailsView() {
   dumpn("NetworkDetailsView was instantiated");
 
+  // The ToolSidebar requires the panel object to be able to emit events.
+  EventEmitter.decorate(this);
+
   this._onTabSelect = this._onTabSelect.bind(this);
 };
 
 NetworkDetailsView.prototype = {
   /**
    * An object containing the state of tabs.
    */
   _viewState: {
@@ -2020,16 +2023,20 @@ NetworkDetailsView.prototype = {
 
   /**
    * Initialization function, called when the network monitor is started.
    */
   initialize: function() {
     dumpn("Initializing the NetworkDetailsView");
 
     this.widget = $("#event-details-pane");
+    this.sidebar = new ToolSidebar(this.widget, this, "netmonitor", {
+      disableTelemetry: true,
+      showAllTabsMenu: true
+    });
 
     this._headers = new VariablesView($("#all-headers"),
       Heritage.extend(GENERIC_VARIABLES_VIEW_SETTINGS, {
         emptyText: L10N.getStr("headersEmptyText"),
         searchPlaceholder: L10N.getStr("headersFilterText")
       }));
     this._cookies = new VariablesView($("#all-cookies"),
       Heritage.extend(GENERIC_VARIABLES_VIEW_SETTINGS, {
@@ -2060,17 +2067,17 @@ NetworkDetailsView.prototype = {
     $("tabpanels", this.widget).addEventListener("select", this._onTabSelect);
   },
 
   /**
    * Destruction function, called when the network monitor is closed.
    */
   destroy: function() {
     dumpn("Destroying the NetworkDetailsView");
-
+    this.sidebar.destroy();
     $("tabpanels", this.widget).removeEventListener("select", this._onTabSelect);
   },
 
   /**
    * Populates this view with the specified data.
    *
    * @param object aData
    *        The data source (this should be the attachment of a request item).
@@ -2085,18 +2092,17 @@ NetworkDetailsView.prototype = {
     $("#response-content-json-box").hidden = true;
     $("#response-content-textarea-box").hidden = true;
     $("#raw-headers").hidden = true;
     $("#response-content-image-box").hidden = true;
 
     let isHtml = RequestsMenuView.prototype.isHtml({ attachment: aData });
 
     // Show the "Preview" tabpanel only for plain HTML responses.
-    $("#preview-tab").hidden = !isHtml;
-    $("#preview-tabpanel").hidden = !isHtml;
+    this.sidebar.toggleTab(isHtml, "preview-tab", "preview-tabpanel");
 
     // Show the "Security" tab only for requests that
     //   1) are https (state != insecure)
     //   2) come from a target that provides security information.
     let hasSecurityInfo = aData.securityState &&
                           aData.securityState !== "insecure";
 
     $("#security-tab").hidden = !hasSecurityInfo;
--- a/browser/devtools/performance/modules/io.js
+++ b/browser/devtools/performance/modules/io.js
@@ -114,17 +114,16 @@ exports.PerformanceIO = PerformanceIO;
  */
 function isValidSerializerVersion (version) {
   return !!~[
     PERF_TOOL_SERIALIZER_LEGACY_VERSION,
     PERF_TOOL_SERIALIZER_CURRENT_VERSION
   ].indexOf(version);
 }
 
-
 /**
  * Takes recording data (with version `1`, from the original profiler tool), and
  * massages the data to be line with the current performance tool's property names
  * and values.
  *
  * @param object legacyData
  * @return object
  */
--- a/browser/devtools/performance/performance-controller.js
+++ b/browser/devtools/performance/performance-controller.js
@@ -18,44 +18,48 @@ devtools.lazyRequireGetter(this, "DevToo
   "devtools/toolkit/DevToolsUtils");
 
 devtools.lazyRequireGetter(this, "TIMELINE_BLUEPRINT",
   "devtools/timeline/global", true);
 devtools.lazyRequireGetter(this, "L10N",
   "devtools/profiler/global", true);
 devtools.lazyRequireGetter(this, "PerformanceIO",
   "devtools/performance/io", true);
+devtools.lazyRequireGetter(this, "RecordingModel",
+  "devtools/performance/recording-model", true);
+devtools.lazyRequireGetter(this, "RECORDING_IN_PROGRESS",
+  "devtools/performance/recording-model", true);
+devtools.lazyRequireGetter(this, "RECORDING_UNAVAILABLE",
+  "devtools/performance/recording-model", true);
+
 devtools.lazyRequireGetter(this, "MarkersOverview",
   "devtools/timeline/markers-overview", true);
 devtools.lazyRequireGetter(this, "MemoryOverview",
   "devtools/timeline/memory-overview", true);
 devtools.lazyRequireGetter(this, "Waterfall",
   "devtools/timeline/waterfall", true);
 devtools.lazyRequireGetter(this, "MarkerDetails",
   "devtools/timeline/marker-details", true);
 devtools.lazyRequireGetter(this, "CallView",
   "devtools/profiler/tree-view", true);
 devtools.lazyRequireGetter(this, "ThreadNode",
   "devtools/profiler/tree-model", true);
-
+devtools.lazyRequireGetter(this, "FrameNode",
+  "devtools/profiler/tree-model", true);
 
 devtools.lazyImporter(this, "CanvasGraphUtils",
   "resource:///modules/devtools/Graphs.jsm");
 devtools.lazyImporter(this, "LineGraphWidget",
   "resource:///modules/devtools/Graphs.jsm");
-devtools.lazyImporter(this, "SideMenuWidget",
-  "resource:///modules/devtools/SideMenuWidget.jsm");
-
-const { RecordingModel, RECORDING_IN_PROGRESS, RECORDING_UNAVAILABLE } =
-  devtools.require("devtools/performance/recording-model");
-
 devtools.lazyImporter(this, "FlameGraphUtils",
   "resource:///modules/devtools/FlameGraph.jsm");
 devtools.lazyImporter(this, "FlameGraph",
   "resource:///modules/devtools/FlameGraph.jsm");
+devtools.lazyImporter(this, "SideMenuWidget",
+  "resource:///modules/devtools/SideMenuWidget.jsm");
 
 // Events emitted by various objects in the panel.
 const EVENTS = {
   // Emitted by the PerformanceController or RecordingView
   // when a recording model is selected
   RECORDING_SELECTED: "Performance:RecordingSelected",
 
   // Emitted by the PerformanceView on record button click
@@ -198,29 +202,29 @@ let PerformanceController = {
     gFront.off("memory", this._onTimelineData);
   },
 
   /**
    * Starts recording with the PerformanceFront. Emits `EVENTS.RECORDING_STARTED`
    * when the front has started to record.
    */
   startRecording: Task.async(function *() {
-    let model = this.createNewRecording();
-    this.setCurrentRecording(model);
-    yield model.startRecording();
+    let recording = this.createNewRecording();
+    this.setCurrentRecording(recording);
+    yield recording.startRecording();
 
-    this.emit(EVENTS.RECORDING_STARTED, model);
+    this.emit(EVENTS.RECORDING_STARTED, recording);
   }),
 
   /**
    * Stops recording with the PerformanceFront. Emits `EVENTS.RECORDING_STOPPED`
    * when the front has stopped recording.
    */
   stopRecording: Task.async(function *() {
-    let recording = this._getLatest();
+    let recording = this._getLatestRecording();
     yield recording.stopRecording();
 
     this.emit(EVENTS.RECORDING_STOPPED, recording);
   }),
 
   /**
    * Saves the current recording to a file.
    *
@@ -238,153 +242,99 @@ let PerformanceController = {
 
   /**
    * Loads a recording from a file, adding it to the recordings list.
    *
    * @param nsILocalFile file
    *        The file to import the data from.
    */
   importRecording: Task.async(function*(_, file) {
-    let model = this.createNewRecording();
-    yield model.importRecording(file);
+    let recording = this.createNewRecording();
+    yield recording.importRecording(file);
 
-    this.emit(EVENTS.RECORDING_IMPORTED, model.getAllData(), model);
+    this.emit(EVENTS.RECORDING_IMPORTED, recording);
   }),
 
   /**
    * Creates a new RecordingModel, fires events and stores it
    * internally in the controller.
+   *
+   * @return RecordingModel
+   *         The newly created recording model.
    */
   createNewRecording: function () {
-    let model = new RecordingModel({
+    let recording = new RecordingModel({
       front: gFront,
       performance: performance
     });
-    this._recordings.push(model);
-    this.emit(EVENTS.RECORDING_CREATED, model);
-    return model;
+    this._recordings.push(recording);
+
+    this.emit(EVENTS.RECORDING_CREATED, recording);
+    return recording;
   },
 
   /**
-   * Sets the active RecordingModel to `recording`.
+   * Sets the currently active RecordingModel.
+   * @param RecordingModel recording
    */
   setCurrentRecording: function (recording) {
     if (this._currentRecording !== recording) {
       this._currentRecording = recording;
       this.emit(EVENTS.RECORDING_SELECTED, recording);
     }
   },
 
   /**
-   * Return the current active RecordingModel.
+   * Gets the currently active RecordingModel.
+   * @return RecordingModel
    */
   getCurrentRecording: function () {
     return this._currentRecording;
   },
 
   /**
-   * Gets the amount of time elapsed locally after starting a recording.
-   */
-  getLocalElapsedTime: function () {
-    return this.getCurrentRecording().getLocalElapsedTime;
-  },
-
-  /**
-   * Gets the time interval for the current recording.
-   * @return object
-   */
-  getInterval: function() {
-    return this.getCurrentRecording().getInterval();
-  },
-
-  /**
-   * Gets the accumulated markers in the current recording.
-   * @return array
-   */
-  getMarkers: function() {
-    return this.getCurrentRecording().getMarkers();
-  },
-
-  /**
-   * Gets the accumulated stack frames in the current recording.
-   * @return array
+   * Get most recently added recording that was triggered manually (via UI).
+   * @return RecordingModel
    */
-  getFrames: function() {
-    return this.getCurrentRecording().getFrames();
-  },
-
-  /**
-   * Gets the accumulated memory measurements in this recording.
-   * @return array
-   */
-  getMemory: function() {
-    return this.getCurrentRecording().getMemory();
-  },
-
-  /**
-   * Gets the accumulated refresh driver ticks in this recording.
-   * @return array
-   */
-  getTicks: function() {
-    return this.getCurrentRecording().getTicks();
-  },
-
-  /**
-   * Gets the profiler data in this recording.
-   * @return array
-   */
-  getProfilerData: function() {
-    return this.getCurrentRecording().getProfilerData();
-  },
-
-  /**
-   * Gets all the data in this recording.
-   */
-  getAllData: function() {
-    return this.getCurrentRecording().getAllData();
-  },
-
-  /**
-  /**
-   * Get most recently added profile that was triggered manually (via UI)
-   */
-  _getLatest: function () {
+  _getLatestRecording: function () {
     for (let i = this._recordings.length - 1; i >= 0; i--) {
       return this._recordings[i];
     }
     return null;
   },
 
   /**
    * Fired whenever the PerformanceFront emits markers, memory or ticks.
    */
   _onTimelineData: function (...data) {
     this._recordings.forEach(profile => profile.addTimelineData.apply(profile, data));
     this.emit(EVENTS.TIMELINE_DATA, ...data);
   },
 
   /**
-   * Fired from RecordingsView, we listen on the PerformanceController
-   * so we can set it here and re-emit on the controller, where all views can listen.
+   * Fired from RecordingsView, we listen on the PerformanceController so we can
+   * set it here and re-emit on the controller, where all views can listen.
    */
   _onRecordingSelectFromView: function (_, recording) {
     this.setCurrentRecording(recording);
   }
 };
 
 /**
  * Convenient way of emitting events from the controller.
  */
 EventEmitter.decorate(PerformanceController);
 
 /**
  * Shortcuts for accessing various profiler preferences.
  */
 const Prefs = new ViewHelpers.Prefs("devtools.profiler", {
-  showPlatformData: ["Bool", "ui.show-platform-data"]
+  flattenTreeRecursion: ["Bool", "ui.flatten-tree-recursion"],
+  showPlatformData: ["Bool", "ui.show-platform-data"],
+  showIdleBlocks: ["Bool", "ui.show-idle-blocks"],
 });
 
 /**
  * DOM query helpers.
  */
 function $(selector, target = document) {
   return target.querySelector(selector);
 }
--- a/browser/devtools/performance/test/browser_perf-details-flamegraph-render-01.js
+++ b/browser/devtools/performance/test/browser_perf-details-flamegraph-render-01.js
@@ -4,17 +4,17 @@
 /**
  * Tests that the flamegraph view renders content after recording.
  */
 function spawnTest () {
   let { panel } = yield initPerformance(SIMPLE_URL);
   let { EVENTS, PerformanceController, FlameGraphView } = panel.panelWin;
 
   yield startRecording(panel);
-  yield waitUntil(() => PerformanceController.getMarkers().length);
+  yield waitUntil(() => PerformanceController.getCurrentRecording().getMarkers().length);
 
   let rendered = once(FlameGraphView, EVENTS.FLAMEGRAPH_RENDERED);
   yield stopRecording(panel);
   yield rendered;
 
   ok(true, "FlameGraphView rendered after recording is stopped.");
 
   yield teardown(panel);
--- a/browser/devtools/performance/test/browser_perf-details-waterfall-render-01.js
+++ b/browser/devtools/performance/test/browser_perf-details-waterfall-render-01.js
@@ -4,17 +4,17 @@
 /**
  * Tests that the waterfall view renders content after recording.
  */
 function spawnTest () {
   let { panel } = yield initPerformance(SIMPLE_URL);
   let { EVENTS, PerformanceController, WaterfallView } = panel.panelWin;
 
   yield startRecording(panel);
-  yield waitUntil(() => PerformanceController.getMarkers().length);
+  yield waitUntil(() => PerformanceController.getCurrentRecording().getMarkers().length);
 
   let rendered = once(WaterfallView, EVENTS.WATERFALL_RENDERED);
   yield stopRecording(panel);
   yield rendered;
 
   ok(true, "WaterfallView rendered after recording is stopped.");
 
   yield teardown(panel);
--- a/browser/devtools/performance/test/browser_perf_recordings-io-01.js
+++ b/browser/devtools/performance/test/browser_perf_recordings-io-01.js
@@ -9,17 +9,17 @@ let test = Task.async(function*() {
   let { target, panel, toolbox } = yield initPerformance(SIMPLE_URL);
   let { EVENTS, PerformanceController } = panel.panelWin;
 
   yield startRecording(panel);
   yield stopRecording(panel);
 
   // Verify original recording.
 
-  let originalData = PerformanceController.getAllData();
+  let originalData = PerformanceController.getCurrentRecording().getAllData();
   ok(originalData, "The original recording is not empty.");
 
   // Save recording.
 
   let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]);
   file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8));
 
   let exported = once(PerformanceController, EVENTS.RECORDING_EXPORTED);
@@ -37,17 +37,17 @@ let test = Task.async(function*() {
   yield imported;
   ok(true, "The recording data appears to have been successfully imported.");
 
   yield rerendered;
   ok(true, "The imported data was re-rendered.");
 
   // Verify imported recording.
 
-  let importedData = PerformanceController.getAllData();
+  let importedData = PerformanceController.getCurrentRecording().getAllData();
 
   is(importedData.startTime, originalData.startTime,
     "The impored data is identical to the original data (1).");
   is(importedData.endTime, originalData.endTime,
     "The impored data is identical to the original data (2).");
 
   is(importedData.markers.toSource(), originalData.markers.toSource(),
     "The impored data is identical to the original data (3).");
--- a/browser/devtools/performance/test/browser_perf_recordings-io-04.js
+++ b/browser/devtools/performance/test/browser_perf_recordings-io-04.js
@@ -9,17 +9,17 @@
 let test = Task.async(function*() {
   let { target, panel, toolbox } = yield initPerformance(SIMPLE_URL);
   let { EVENTS, PerformanceController } = panel.panelWin;
 
   yield startRecording(panel);
   yield stopRecording(panel);
 
   // Get data from the current profiler
-  let data = PerformanceController.getAllData();
+  let data = PerformanceController.getCurrentRecording().getAllData();
 
   // Create a structure from the data that mimics the old profiler's data.
   // Different name for `ticks`, different way of storing time,
   // and no memory, markers data.
   let oldProfilerData = {
     recordingDuration: data.interval.endTime - data.interval.startTime,
     ticksData: data.ticks,
     profilerData: data.profilerData,
@@ -41,17 +41,17 @@ let test = Task.async(function*() {
   yield imported;
   ok(true, "The original profiler data appears to have been successfully imported.");
 
   yield rerendered;
   ok(true, "The imported data was re-rendered.");
 
   // Verify imported recording.
 
-  let importedData = PerformanceController.getAllData();
+  let importedData = PerformanceController.getCurrentRecording().getAllData();
 
   is(importedData.startTime, data.startTime,
     "The imported legacy data was successfully converted for the current tool (1).");
   is(importedData.endTime, data.endTime,
     "The imported legacy data was successfully converted for the current tool (2).");
   is(importedData.markers.toSource(), [].toSource(),
     "The imported legacy data was successfully converted for the current tool (3).");
   is(importedData.memory.toSource(), [].toSource(),
--- a/browser/devtools/performance/views/details-call-tree.js
+++ b/browser/devtools/performance/views/details-call-tree.js
@@ -68,17 +68,18 @@ let CallTreeView = {
   },
 
   /**
    * Fired when a range is selected or cleared in the OverviewView.
    */
   _onRangeChange: function (_, params) {
     // When a range is cleared, we'll have no beginAt/endAt data,
     // so the rebuild will just render all the data again.
-    let profilerData = PerformanceController.getProfilerData();
+    let recording = PerformanceController.getCurrentRecording();
+    let profilerData = recording.getProfilerData();
     let { beginAt, endAt } = params || {};
     this.render(profilerData, beginAt, endAt);
   },
 
   /**
    * Fired on the "link" event for the call tree in this container.
    */
   _onLink: function (_, treeItem) {
--- a/browser/devtools/performance/views/details-flamegraph.js
+++ b/browser/devtools/performance/views/details-flamegraph.js
@@ -37,17 +37,21 @@ let FlameGraphView = {
    * Method for handling all the set up for rendering a new flamegraph.
    */
   render: function (profilerData) {
     // Empty recordings might yield no profiler data.
     if (profilerData.profile == null) {
       return;
     }
     let samples = profilerData.profile.threads[0].samples;
-    let dataSrc = FlameGraphUtils.createFlameGraphDataFromSamples(samples);
+    let dataSrc = FlameGraphUtils.createFlameGraphDataFromSamples(samples, {
+      flattenRecursion: Prefs.flattenTreeRecursion,
+      filterFrames: !Prefs.showPlatformData && FrameNode.isContent,
+      showIdleBlocks: Prefs.showIdleBlocks && L10N.getStr("table.idle")
+    });
     this.graph.setData(dataSrc);
     this.emit(EVENTS.FLAMEGRAPH_RENDERED);
   },
 
   /**
    * Called when recording is stopped.
    */
   _onRecordingStopped: function () {
--- a/browser/devtools/performance/views/details-waterfall.js
+++ b/browser/devtools/performance/views/details-waterfall.js
@@ -43,18 +43,19 @@ let WaterfallView = {
     PerformanceController.off(EVENTS.RECORDING_STOPPED, this._onRecordingStopped);
     PerformanceController.off(EVENTS.RECORDING_SELECTED, this._onRecordingSelected);
   },
 
   /**
    * Method for handling all the set up for rendering a new waterfall.
    */
   render: function() {
-    let { startTime, endTime } = PerformanceController.getInterval();
-    let markers = PerformanceController.getMarkers();
+    let recording = PerformanceController.getCurrentRecording();
+    let { startTime, endTime } = recording.getInterval();
+    let markers = recording.getMarkers();
 
     this.waterfall.setData(markers, startTime, startTime, endTime);
 
     this.emit(EVENTS.WATERFALL_RENDERED);
   },
 
   /**
    * Called when recording starts.
@@ -79,22 +80,21 @@ let WaterfallView = {
     }
   },
 
   /**
    * Called when a marker is selected in the waterfall view,
    * updating the markers detail view.
    */
   _onMarkerSelected: function (event, marker) {
+    let recording = PerformanceController.getCurrentRecording();
+    let frames = recording.getFrames();
+
     if (event === "selected") {
-      this.details.render({
-        toolbox: gToolbox,
-        marker: marker,
-        frames: PerformanceController.getFrames()
-      });
+      this.details.render({ toolbox: gToolbox, marker, frames });
     }
     if (event === "unselected") {
       this.details.empty();
     }
   },
 
   /**
    * Called when the marker details view is resized.
--- a/browser/devtools/performance/views/overview.js
+++ b/browser/devtools/performance/views/overview.js
@@ -107,20 +107,21 @@ let OverviewView = {
 
   /**
    * Method for handling all the set up for rendering the overview graphs.
    *
    * @param number resolution
    *        The fps graph resolution. @see Graphs.jsm
    */
   render: Task.async(function *(resolution) {
-    let interval = PerformanceController.getInterval();
-    let markers = PerformanceController.getMarkers();
-    let memory = PerformanceController.getMemory();
-    let timestamps = PerformanceController.getTicks();
+    let recording = PerformanceController.getCurrentRecording();
+    let interval = recording.getInterval();
+    let markers = recording.getMarkers();
+    let memory = recording.getMemory();
+    let timestamps = recording.getTicks();
 
     this.markersOverview.setData({ interval, markers });
     this.emit(EVENTS.MARKERS_GRAPH_RENDERED);
 
     this.memoryOverview.setData({ interval, memory });
     this.emit(EVENTS.MEMORY_GRAPH_RENDERED);
 
     yield this.framerateGraph.setDataFromTimestamps(timestamps, resolution);
--- a/browser/devtools/performance/views/recordings.js
+++ b/browser/devtools/performance/views/recordings.js
@@ -146,22 +146,20 @@ let RecordingsView = Heritage.extend(Wid
     // Render the recording item with finalized information (timing, etc)
     this.finalizeRecording(recordingItem);
     this.forceSelect(recordingItem);
   },
 
   /**
    * Signals that a recording has been imported.
    *
-   * @param object recordingData
-   *        The profiler and refresh driver ticks data received from the front.
    * @param RecordingModel model
    *        The recording model containing data on the recording session.
    */
-  _onRecordingImported: function (_, recordingData, model) {
+  _onRecordingImported: function (_, model) {
     let recordingItem = this.addEmptyRecording(model);
     recordingItem.isRecording = false;
 
     // Immediately select the imported recording
     this.selectedItem = recordingItem;
 
     // Render the recording item with finalized information (timing, etc)
     this.finalizeRecording(recordingItem);
--- a/browser/devtools/profiler/test/browser_profiler_content-check.js
+++ b/browser/devtools/profiler/test/browser_profiler_content-check.js
@@ -2,50 +2,50 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /**
  * Tests the function testing whether or not a frame is content or chrome
  * works properly.
  */
 
 function test() {
-  let { _isContent } = devtools.require("devtools/profiler/tree-model");
+  let { FrameNode } = devtools.require("devtools/profiler/tree-model");
 
-  ok(_isContent({ location: "http://foo" }),
+  ok(FrameNode.isContent({ location: "http://foo" }),
     "Verifying content/chrome frames is working properly.");
-  ok(_isContent({ location: "https://foo" }),
+  ok(FrameNode.isContent({ location: "https://foo" }),
     "Verifying content/chrome frames is working properly.");
-  ok(_isContent({ location: "file://foo" }),
+  ok(FrameNode.isContent({ location: "file://foo" }),
     "Verifying content/chrome frames is working properly.");
 
-  ok(!_isContent({ location: "chrome://foo" }),
+  ok(!FrameNode.isContent({ location: "chrome://foo" }),
     "Verifying content/chrome frames is working properly.");
-  ok(!_isContent({ location: "resource://foo" }),
+  ok(!FrameNode.isContent({ location: "resource://foo" }),
     "Verifying content/chrome frames is working properly.");
 
-  ok(!_isContent({ location: "chrome://foo -> http://bar" }),
+  ok(!FrameNode.isContent({ location: "chrome://foo -> http://bar" }),
     "Verifying content/chrome frames is working properly.");
-  ok(!_isContent({ location: "chrome://foo -> https://bar" }),
+  ok(!FrameNode.isContent({ location: "chrome://foo -> https://bar" }),
     "Verifying content/chrome frames is working properly.");
-  ok(!_isContent({ location: "chrome://foo -> file://bar" }),
+  ok(!FrameNode.isContent({ location: "chrome://foo -> file://bar" }),
     "Verifying content/chrome frames is working properly.");
 
-  ok(!_isContent({ location: "resource://foo -> http://bar" }),
+  ok(!FrameNode.isContent({ location: "resource://foo -> http://bar" }),
     "Verifying content/chrome frames is working properly.");
-  ok(!_isContent({ location: "resource://foo -> https://bar" }),
+  ok(!FrameNode.isContent({ location: "resource://foo -> https://bar" }),
     "Verifying content/chrome frames is working properly.");
-  ok(!_isContent({ location: "resource://foo -> file://bar" }),
+  ok(!FrameNode.isContent({ location: "resource://foo -> file://bar" }),
     "Verifying content/chrome frames is working properly.");
 
-  ok(!_isContent({ category: 1, location: "chrome://foo" }),
+  ok(!FrameNode.isContent({ category: 1, location: "chrome://foo" }),
     "Verifying content/chrome frames is working properly.");
-  ok(!_isContent({ category: 1, location: "resource://foo" }),
+  ok(!FrameNode.isContent({ category: 1, location: "resource://foo" }),
     "Verifying content/chrome frames is working properly.");
 
-  ok(!_isContent({ category: 1, location: "file://foo -> http://bar" }),
+  ok(!FrameNode.isContent({ category: 1, location: "file://foo -> http://bar" }),
     "Verifying content/chrome frames is working properly.");
-  ok(!_isContent({ category: 1, location: "file://foo -> https://bar" }),
+  ok(!FrameNode.isContent({ category: 1, location: "file://foo -> https://bar" }),
     "Verifying content/chrome frames is working properly.");
-  ok(!_isContent({ category: 1, location: "file://foo -> file://bar" }),
+  ok(!FrameNode.isContent({ category: 1, location: "file://foo -> file://bar" }),
     "Verifying content/chrome frames is working properly.");
 
   finish();
 }
--- a/browser/devtools/profiler/utils/tree-model.js
+++ b/browser/devtools/profiler/utils/tree-model.js
@@ -13,17 +13,17 @@ loader.lazyRequireGetter(this, "CATEGORY
 loader.lazyRequireGetter(this, "CATEGORY_JIT",
   "devtools/profiler/global", true);
 
 const CHROME_SCHEMES = ["chrome://", "resource://"];
 const CONTENT_SCHEMES = ["http://", "https://", "file://"];
 
 exports.ThreadNode = ThreadNode;
 exports.FrameNode = FrameNode;
-exports._isContent = isContent; // used in tests
+exports.FrameNode.isContent = isContent;
 
 /**
  * A call tree for a thread. This is essentially a linkage between all frames
  * of all samples into a single tree structure, with additional information
  * on each node, like the time spent (in milliseconds) and samples count.
  *
  * Example:
  * {
--- a/browser/devtools/shared/moz.build
+++ b/browser/devtools/shared/moz.build
@@ -34,16 +34,17 @@ EXTRA_JS_MODULES.devtools += [
 
 EXTRA_JS_MODULES.devtools.shared += [
     'autocomplete-popup.js',
     'd3.js',
     'doorhanger.js',
     'frame-script-utils.js',
     'inplace-editor.js',
     'observable-object.js',
+    'options-view.js',
     'telemetry.js',
     'theme-switching.js',
     'theme.js',
     'undo.js',
 ]
 
 EXTRA_JS_MODULES.devtools.shared.widgets += [
     'widgets/CubicBezierWidget.js',
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/options-view.js
@@ -0,0 +1,165 @@
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const { Services } = require("resource://gre/modules/Services.jsm");
+
+const OPTIONS_SHOWN_EVENT = "options-shown";
+const OPTIONS_HIDDEN_EVENT = "options-hidden";
+const PREF_CHANGE_EVENT = "pref-changed";
+
+/**
+ * OptionsView constructor. Takes several options, all required:
+ * - branchName: The name of the prefs branch, like "devtools.debugger."
+ * - window: The window the XUL elements live in.
+ * - menupopup: The XUL `menupopup` item that contains the pref buttons.
+ *
+ * Fires an event, PREF_CHANGE_EVENT, with the preference name that changed as the second
+ * argument. Fires events on opening/closing the XUL panel (OPTIONS_SHOW_EVENT, OPTIONS_HIDDEN_EVENT)
+ * as the second argument in the listener, used for tests mostly.
+ */
+const OptionsView = function (options={}) {
+  this.branchName = options.branchName;
+  this.window = options.window;
+  this.menupopup = options.menupopup;
+  let { document } = this.window;
+  this.$ = document.querySelector.bind(document);
+  this.$$ = document.querySelectorAll.bind(document);
+
+  this.prefObserver = new PrefObserver(this.branchName);
+
+  EventEmitter.decorate(this);
+};
+exports.OptionsView = OptionsView;
+
+OptionsView.prototype = {
+  /**
+   * Binds the events and observers for the OptionsView.
+   */
+  initialize: function () {
+    let { MutationObserver } = this.window;
+    this._onPrefChange = this._onPrefChange.bind(this);
+    this._onOptionChange = this._onOptionChange.bind(this);
+    this._onPopupShown = this._onPopupShown.bind(this);
+    this._onPopupHidden = this._onPopupHidden.bind(this);
+
+    // We use a mutation observer instead of a click handler
+    // because the click handler is fired before the XUL menuitem updates
+    // it's checked status, which cascades incorrectly with the Preference observer.
+    this.mutationObserver = new MutationObserver(this._onOptionChange);
+    let observerConfig = { attributes: true, attributeFilter: ["checked"]};
+
+    // Sets observers and default options for all options
+    for (let $el of this.$$("menuitem", this.menupopup)) {
+      let prefName = $el.getAttribute("data-pref");
+
+      if (this.prefObserver.get(prefName)) {
+        $el.setAttribute("checked", "true");
+      } else {
+        $el.removeAttribute("checked");
+      }
+      this.mutationObserver.observe($el, observerConfig);
+    }
+
+    // Listen to any preference change in the specified branch
+    this.prefObserver.register();
+    this.prefObserver.on(PREF_CHANGE_EVENT, this._onPrefChange);
+
+    // Bind to menupopup's open and close event
+    this.menupopup.addEventListener("popupshown", this._onPopupShown);
+    this.menupopup.addEventListener("popuphidden", this._onPopupHidden);
+  },
+
+  /**
+   * Removes event handlers for all of the option buttons and
+   * preference observer.
+   */
+  destroy: function () {
+    this.mutationObserver.disconnect();
+    this.prefObserver.off(PREF_CHANGE_EVENT, this._onPrefChange);
+    this.menupopup.removeEventListener("popupshown", this._onPopupShown);
+    this.menupopup.removeEventListener("popuphidden", this._onPopupHidden);
+  },
+
+  /**
+   * Called when a preference is changed (either via clicking an option
+   * button or by changing it in about:config). Updates the checked status
+   * of the corresponding button.
+   */
+  _onPrefChange: function (_, prefName) {
+    let $el = this.$(`menuitem[data-pref="${prefName}"]`, this.menupopup);
+    let value = this.prefObserver.get(prefName);
+
+    if (value) {
+      $el.setAttribute("checked", value);
+    } else {
+      $el.removeAttribute("checked");
+    }
+
+    this.emit(PREF_CHANGE_EVENT, prefName);
+  },
+
+  /**
+   * Mutation handler for handling a change on an options button.
+   * Sets the preference accordingly.
+   */
+  _onOptionChange: function (mutations) {
+    let { target } = mutations[0];
+    let prefName = target.getAttribute("data-pref");
+    let value = target.getAttribute("checked") === "true";
+
+    this.prefObserver.set(prefName, value);
+  },
+
+  /**
+   * Fired when the `menupopup` is opened, bound via XUL.
+   * Fires an event used in tests.
+   */
+  _onPopupShown: function () {
+    this.emit(OPTIONS_SHOWN_EVENT);
+  },
+
+  /**
+   * Fired when the `menupopup` is closed, bound via XUL.
+   * Fires an event used in tests.
+   */
+  _onPopupHidden: function () {
+    this.emit(OPTIONS_HIDDEN_EVENT);
+  }
+};
+
+/**
+ * Constructor for PrefObserver. Small helper for observing changes
+ * on a preference branch. Takes a `branchName`, like "devtools.debugger."
+ *
+ * Fires an event of PREF_CHANGE_EVENT with the preference name that changed
+ * as the second argument in the listener.
+ */
+const PrefObserver = function (branchName) {
+  this.branchName = branchName;
+  this.branch = Services.prefs.getBranch(branchName);
+  EventEmitter.decorate(this);
+};
+
+PrefObserver.prototype = {
+  /**
+   * Returns `prefName`'s value. Does not require the branch name.
+   */
+  get: function (prefName) {
+    let fullName = this.branchName + prefName;
+    return Services.prefs.getBoolPref(fullName);
+  },
+  /**
+   * Sets `prefName`'s `value`. Does not require the branch name.
+   */
+  set: function (prefName, value) {
+    let fullName = this.branchName + prefName;
+    Services.prefs.setBoolPref(fullName, value);
+  },
+  register: function () {
+    this.branch.addObserver("", this, false);
+  },
+  unregister: function () {
+    this.branch.removeObserver("", this);
+  },
+  observe: function (subject, topic, prefName) {
+    this.emit(PREF_CHANGE_EVENT, prefName);
+  }
+};
--- a/browser/devtools/shared/test/browser.ini
+++ b/browser/devtools/shared/test/browser.ini
@@ -3,29 +3,33 @@ skip-if = e10s # Bug ?????? - devtools t
 subsuite = devtools
 support-files =
   browser_layoutHelpers.html
   browser_layoutHelpers-getBoxQuads.html
   browser_layoutHelpers_iframe.html
   browser_templater_basic.html
   browser_toolbar_basic.html
   browser_toolbar_webconsole_errors_count.html
+  doc_options-view.xul
   head.js
   leakhunt.js
 
 [browser_css_color.js]
 [browser_cubic-bezier-01.js]
 [browser_cubic-bezier-02.js]
 [browser_cubic-bezier-03.js]
 [browser_flame-graph-01.js]
 [browser_flame-graph-02.js]
 [browser_flame-graph-03a.js]
 [browser_flame-graph-03b.js]
 [browser_flame-graph-04.js]
-[browser_flame-graph-utils.js]
+[browser_flame-graph-utils-01.js]
+[browser_flame-graph-utils-02.js]
+[browser_flame-graph-utils-03.js]
+[browser_flame-graph-utils-04.js]
 [browser_graphs-01.js]
 [browser_graphs-02.js]
 [browser_graphs-03.js]
 [browser_graphs-04.js]
 [browser_graphs-05.js]
 [browser_graphs-06.js]
 [browser_graphs-07a.js]
 [browser_graphs-07b.js]
@@ -80,8 +84,9 @@ skip-if = e10s # Bug 1086492 - Disable t
 [browser_templater_basic.js]
 [browser_toolbar_basic.js]
 [browser_toolbar_tooltip.js]
 [browser_toolbar_webconsole_errors_count.js]
 skip-if = buildapp == 'mulet'
 [browser_treeWidget_basic.js]
 [browser_treeWidget_keyboard_interaction.js]
 [browser_treeWidget_mouse_interaction.js]
+[browser_options-view-01.js]
rename from browser/devtools/shared/test/browser_flame-graph-utils.js
rename to browser/devtools/shared/test/browser_flame-graph-utils-01.js
--- a/browser/devtools/shared/test/browser_flame-graph-utils.js
+++ b/browser/devtools/shared/test/browser_flame-graph-utils-01.js
@@ -1,12 +1,13 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
-// Tests that text metrics in the flame graph widget work properly.
+// Tests that text metrics and data conversion from profiler samples
+// widget work properly in the flame graph.
 
 let {FlameGraphUtils} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
 
 let test = Task.async(function*() {
   yield promiseTab("about:blank");
   yield performTest();
   gBrowser.removeCurrentTab();
   finish();
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_flame-graph-utils-02.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests consecutive duplicate frames are removed from the flame graph data.
+
+let {FlameGraphUtils} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+
+let test = Task.async(function*() {
+  yield promiseTab("about:blank");
+  yield performTest();
+  gBrowser.removeCurrentTab();
+  finish();
+});
+
+function* performTest() {
+  let out = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA, {
+    flattenRecursion: true
+  });
+
+  ok(out, "Some data was outputted properly");
+  is(out.length, 10, "The outputted length is correct.");
+
+  info("Got flame graph data:\n" + out.toSource() + "\n");
+
+  for (let i = 0; i < out.length; i++) {
+    let found = out[i];
+    let expected = EXPECTED_OUTPUT[i];
+
+    is(found.blocks.length, expected.blocks.length,
+      "The correct number of blocks were found in this bucket.");
+
+    for (let j = 0; j < found.blocks.length; j++) {
+      is(found.blocks[j].x, expected.blocks[j].x,
+        "The expected block X position is correct for this frame.");
+      is(found.blocks[j].y, expected.blocks[j].y,
+        "The expected block Y position is correct for this frame.");
+      is(found.blocks[j].width, expected.blocks[j].width,
+        "The expected block width is correct for this frame.");
+      is(found.blocks[j].height, expected.blocks[j].height,
+        "The expected block height is correct for this frame.");
+      is(found.blocks[j].text, expected.blocks[j].text,
+        "The expected block text is correct for this frame.");
+    }
+  }
+}
+
+let TEST_DATA = [{
+  frames: [{
+    location: "A"
+  }, {
+    location: "A"
+  }, {
+    location: "A"
+  }, {
+    location: "B",
+  }, {
+    location: "B",
+  }, {
+    location: "C"
+  }],
+  time: 50,
+}];
+
+let EXPECTED_OUTPUT = [{
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: [{
+    srcData: {
+      startTime: 0,
+      rawLocation: "A"
+    },
+    x: 0,
+    y: 0,
+    width: 50,
+    height: 11,
+    text: "A"
+  }]
+}, {
+  blocks: [{
+    srcData: {
+      startTime: 0,
+      rawLocation: "B"
+    },
+    x: 0,
+    y: 11,
+    width: 50,
+    height: 11,
+    text: "B"
+  }]
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: []
+}];
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_flame-graph-utils-03.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if platform frames are removed from the flame graph data.
+
+let {FlameGraphUtils} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {FrameNode} = devtools.require("devtools/profiler/tree-model");
+
+let test = Task.async(function*() {
+  yield promiseTab("about:blank");
+  yield performTest();
+  gBrowser.removeCurrentTab();
+  finish();
+});
+
+function* performTest() {
+  let out = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA, {
+    filterFrames: FrameNode.isContent
+  });
+
+  ok(out, "Some data was outputted properly");
+  is(out.length, 10, "The outputted length is correct.");
+
+  info("Got flame graph data:\n" + out.toSource() + "\n");
+
+  for (let i = 0; i < out.length; i++) {
+    let found = out[i];
+    let expected = EXPECTED_OUTPUT[i];
+
+    is(found.blocks.length, expected.blocks.length,
+      "The correct number of blocks were found in this bucket.");
+
+    for (let j = 0; j < found.blocks.length; j++) {
+      is(found.blocks[j].x, expected.blocks[j].x,
+        "The expected block X position is correct for this frame.");
+      is(found.blocks[j].y, expected.blocks[j].y,
+        "The expected block Y position is correct for this frame.");
+      is(found.blocks[j].width, expected.blocks[j].width,
+        "The expected block width is correct for this frame.");
+      is(found.blocks[j].height, expected.blocks[j].height,
+        "The expected block height is correct for this frame.");
+      is(found.blocks[j].text, expected.blocks[j].text,
+        "The expected block text is correct for this frame.");
+    }
+  }
+}
+
+let TEST_DATA = [{
+  frames: [{
+    location: "http://A"
+  }, {
+    location: "https://B"
+  }, {
+    location: "file://C",
+  }, {
+    location: "chrome://D"
+  }, {
+    location: "resource://E"
+  }],
+  time: 50,
+}];
+
+let EXPECTED_OUTPUT = [{
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: [{
+    srcData: {
+      startTime: 0,
+      rawLocation: "http://A"
+    },
+    x: 0,
+    y: 0,
+    width: 50,
+    height: 11,
+    text: "http://A"
+  }, {
+    srcData: {
+      startTime: 0,
+      rawLocation: "file://C"
+    },
+    x: 0,
+    y: 22,
+    width: 50,
+    height: 11,
+    text: "file://C"
+  }]
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: [{
+    srcData: {
+      startTime: 0,
+      rawLocation: "https://B"
+    },
+    x: 0,
+    y: 11,
+    width: 50,
+    height: 11,
+    text: "https://B"
+  }]
+}, {
+  blocks: []
+}];
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_flame-graph-utils-04.js
@@ -0,0 +1,167 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if (idle) nodes are added when necessary in the flame graph data.
+
+let {FlameGraphUtils} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {FrameNode} = devtools.require("devtools/profiler/tree-model");
+
+let test = Task.async(function*() {
+  yield promiseTab("about:blank");
+  yield performTest();
+  gBrowser.removeCurrentTab();
+  finish();
+});
+
+function* performTest() {
+  let out = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA, {
+    flattenRecursion: true,
+    filterFrames: FrameNode.isContent,
+    showIdleBlocks: "\m/"
+  });
+
+  ok(out, "Some data was outputted properly");
+  is(out.length, 10, "The outputted length is correct.");
+
+  info("Got flame graph data:\n" + out.toSource() + "\n");
+
+  for (let i = 0; i < out.length; i++) {
+    let found = out[i];
+    let expected = EXPECTED_OUTPUT[i];
+
+    is(found.blocks.length, expected.blocks.length,
+      "The correct number of blocks were found in this bucket.");
+
+    for (let j = 0; j < found.blocks.length; j++) {
+      is(found.blocks[j].x, expected.blocks[j].x,
+        "The expected block X position is correct for this frame.");
+      is(found.blocks[j].y, expected.blocks[j].y,
+        "The expected block Y position is correct for this frame.");
+      is(found.blocks[j].width, expected.blocks[j].width,
+        "The expected block width is correct for this frame.");
+      is(found.blocks[j].height, expected.blocks[j].height,
+        "The expected block height is correct for this frame.");
+      is(found.blocks[j].text, expected.blocks[j].text,
+        "The expected block text is correct for this frame.");
+    }
+  }
+}
+
+let TEST_DATA = [{
+  frames: [{
+    location: "http://A"
+  }, {
+    location: "http://A"
+  }, {
+    location: "http://A"
+  }, {
+    location: "https://B"
+  }, {
+    location: "https://B"
+  }, {
+    location: "file://C",
+  }, {
+    location: "chrome://D"
+  }, {
+    location: "resource://E"
+  }],
+  time: 50
+}, {
+  frames: [{
+    location: "chrome://D"
+  }, {
+    location: "resource://E"
+  }],
+  time: 100
+}, {
+  frames: [{
+    location: "http://A"
+  }, {
+    location: "https://B"
+  }, {
+    location: "file://C",
+  }],
+  time: 150
+}];
+
+let EXPECTED_OUTPUT = [{
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: [{
+    srcData: {
+      startTime: 0,
+      rawLocation: "http://A"
+    },
+    x: 0,
+    y: 0,
+    width: 50,
+    height: 11,
+    text: "http://A"
+  }, {
+    srcData: {
+      startTime: 0,
+      rawLocation: "file://C"
+    },
+    x: 0,
+    y: 22,
+    width: 50,
+    height: 11,
+    text: "file://C"
+  }, {
+    srcData: {
+      startTime: 100,
+      rawLocation: "http://A"
+    },
+    x: 100,
+    y: 0,
+    width: 50,
+    height: 11,
+    text: "http://A"
+  }]
+}, {
+  blocks: [{
+    srcData: {
+      startTime: 50,
+      rawLocation: "\m/"
+    },
+    x: 50,
+    y: 0,
+    width: 50,
+    height: 11,
+    text: "\m/"
+  }]
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: []
+}, {
+  blocks: [{
+    srcData: {
+      startTime: 0,
+      rawLocation: "https://B"
+    },
+    x: 0,
+    y: 11,
+    width: 50,
+    height: 11,
+    text: "https://B"
+  }, {
+    srcData: {
+      startTime: 100,
+      rawLocation: "https://B"
+    },
+    x: 100,
+    y: 11,
+    width: 50,
+    height: 11,
+    text: "https://B"
+  }]
+}, {
+  blocks: []
+}];
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_options-view-01.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that options-view OptionsView responds to events correctly.
+
+let { OptionsView } = devtools.require("devtools/shared/options-view");
+let { Services } = devtools.require("resource://gre/modules/Services.jsm");
+
+const BRANCH = "devtools.debugger.";
+const BLACK_BOX_PREF = "auto-black-box";
+const PRETTY_PRINT_PREF = "auto-pretty-print";
+
+let originalBlackBox = Services.prefs.getBoolPref(BRANCH + BLACK_BOX_PREF);
+let originalPrettyPrint = Services.prefs.getBoolPref(BRANCH + PRETTY_PRINT_PREF);
+
+let test = Task.async(function*() {
+  Services.prefs.setBoolPref(BRANCH + BLACK_BOX_PREF, false);
+  Services.prefs.setBoolPref(BRANCH + PRETTY_PRINT_PREF, true);
+  let tab = yield promiseTab(OPTIONS_VIEW_URL);
+
+  yield testOptionsView(tab);
+  gBrowser.removeCurrentTab();
+  cleanup();
+  finish();
+});
+
+function* testOptionsView(tab) {
+  let events = [];
+  let options = createOptionsView(tab);
+  yield options.initialize();
+
+  let window = tab._contentWindow;
+  let $ = window.document.querySelector.bind(window.document);
+
+  options.on("pref-changed", (_, pref) => events.push(pref));
+
+  let ppEl = $("menuitem[data-pref='auto-pretty-print']");
+  let bbEl = $("menuitem[data-pref='auto-black-box']");
+
+  // Test default config
+  is(ppEl.getAttribute("checked"), "true", "`true` prefs are checked on start");
+  is(bbEl.getAttribute("checked"), "", "`false` prefs are unchecked on start");
+
+  // Test buttons update when preferences update outside of the menu
+  Services.prefs.setBoolPref(BRANCH + PRETTY_PRINT_PREF, false);
+  Services.prefs.setBoolPref(BRANCH + BLACK_BOX_PREF, true);
+  is(ppEl.getAttribute("checked"), "", "menuitems update when preferences change");
+  is(bbEl.getAttribute("checked"), "true", "menuitems update when preferences change");
+
+  // Tests events are fired when preferences update outside of the menu
+  is(events.length, 2, "two 'pref-changed' events fired");
+  is(events[0], "auto-pretty-print", "correct pref passed in 'pref-changed' event (auto-pretty-print)");
+  is(events[1], "auto-black-box", "correct pref passed in 'pref-changed' event (auto-black-box)");
+
+  // Test buttons update when clicked and preferences are updated
+  yield click(options, window, ppEl);
+  is(ppEl.getAttribute("checked"), "true", "menuitems update when clicked");
+  is(Services.prefs.getBoolPref(BRANCH + PRETTY_PRINT_PREF), true, "preference updated via click");
+
+  yield click(options, window, bbEl);
+  is(bbEl.getAttribute("checked"), "", "menuitems update when clicked");
+  is(Services.prefs.getBoolPref(BRANCH + BLACK_BOX_PREF), false, "preference updated via click");
+
+  // Tests events are fired when preferences updated via click
+  is(events.length, 4, "two 'pref-changed' events fired");
+  is(events[2], "auto-pretty-print", "correct pref passed in 'pref-changed' event (auto-pretty-print)");
+  is(events[3], "auto-black-box", "correct pref passed in 'pref-changed' event (auto-black-box)");
+}
+
+function wait(window) {
+  return new Promise(function (resolve, reject) {
+  window.setTimeout(() => resolve, 60000);
+  });
+}
+function createOptionsView (tab) {
+  return new OptionsView({
+    branchName: BRANCH,
+    window: tab._contentWindow,
+    menupopup: tab._contentWindow.document.querySelector("#options-menupopup")
+  });
+}
+
+function cleanup () {
+  Services.prefs.setBoolPref(BRANCH + BLACK_BOX_PREF, originalBlackBox);
+  Services.prefs.setBoolPref(BRANCH + PRETTY_PRINT_PREF, originalPrettyPrint);
+}
+
+function* click (view, win, menuitem) {
+  let opened = view.once("options-shown");
+  let closed = view.once("options-hidden");
+
+  let button = win.document.querySelector("#options-button");
+  EventUtils.synthesizeMouseAtCenter(button, {}, win);
+  yield opened;
+
+  EventUtils.synthesizeMouseAtCenter(menuitem, {}, win);
+  yield closed;
+}
+
+function* openMenu (view, win) {
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/doc_options-view.xul
@@ -0,0 +1,27 @@
+<?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/. -->
+<?xml-stylesheet href="chrome://browser/skin/" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/common.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/devtools/widgets.css" type="text/css"?>
+<!DOCTYPE window []>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+    <popupset id="options-popupset">
+        <menupopup id="options-menupopup" position="before_end">
+            <menuitem id="option-autoprettyprint"
+                      type="checkbox"
+                      data-pref="auto-pretty-print"
+                      label="pretty print"/>
+            <menuitem id="option-autoblackbox"
+                      type="checkbox"
+                      data-pref="auto-black-box"
+                      label="black box"/>
+        </menupopup>
+    </popupset>
+    <button id="options-button"
+            popup="options-menupopup"/>
+</window>
--- a/browser/devtools/shared/test/head.js
+++ b/browser/devtools/shared/test/head.js
@@ -7,16 +7,17 @@ let {console} = Cu.import("resource://gr
 let TargetFactory = devtools.TargetFactory;
 
 gDevTools.testing = true;
 SimpleTest.registerCleanupFunction(() => {
   gDevTools.testing = false;
 });
 
 const TEST_URI_ROOT = "http://example.com/browser/browser/devtools/shared/test/";
+const OPTIONS_VIEW_URL = TEST_URI_ROOT + "doc_options-view.xul";
 
 /**
  * Open a new tab at a URL and call a callback on load
  */
 function addTab(aURL, aCallback)
 {
   waitForExplicitFinish();
 
--- a/browser/devtools/shared/widgets/FlameGraph.jsm
+++ b/browser/devtools/shared/widgets/FlameGraph.jsm
@@ -822,22 +822,30 @@ const COLOR_PALLETTE = Array.from(Array(
  */
 let FlameGraphUtils = {
   /**
    * Converts a list of samples from the profiler data to something that's
    * drawable by a FlameGraph widget.
    *
    * @param array samples
    *        A list of { time, frames: [{ location }] } objects.
+   * @param object options [optional]
+   *        Additional options supported by this operation:
+   *          - flattenRecursion: specifies if identical consecutive frames
+   *                              should be omitted from the output
+   *          - filterFrames: predicate used for filtering all frames, passing
+   *                          in each frame, its index and the sample array
+   *          - showIdleBlocks: adds "idle" blocks when no frames are available
+   *                            using the provided localized text
    * @param array out [optional]
    *        An output storage to reuse for storing the flame graph data.
    * @return array
    *         The flame graph data.
    */
-  createFlameGraphDataFromSamples: function(samples, out = []) {
+  createFlameGraphDataFromSamples: function(samples, options = {}, out = []) {
     // 1. Create a map of colors to arrays, representing buckets of
     // blocks inside the flame graph pyramid sharing the same style.
 
     let buckets = new Map();
 
     for (let color of COLOR_PALLETTE) {
       buckets.set(color, []);
     }
@@ -845,16 +853,34 @@ let FlameGraphUtils = {
     // 2. Populate the buckets by iterating over every frame in every sample.
 
     let prevTime = 0;
     let prevFrames = [];
 
     for (let { frames, time } of samples) {
       let frameIndex = 0;
 
+      // Flatten recursion if preferred, by removing consecutive frames
+      // sharing the same location.
+      if (options.flattenRecursion) {
+        frames = frames.filter(this._isConsecutiveDuplicate);
+      }
+
+      // Apply a provided filter function. This can be used, for example, to
+      // filter out platform frames if only content-related function calls
+      // should be taken into consideration.
+      if (options.filterFrames) {
+        frames = frames.filter(options.filterFrames);
+      }
+
+      // If no frames are available, add a pseudo "idle" block in between.
+      if (options.showIdleBlocks && frames.length == 0) {
+        frames = [{ location: options.showIdleBlocks || "" }];
+      }
+
       for (let { location } of frames) {
         let prevFrame = prevFrames[frameIndex];
 
         // Frames at the same location and the same depth will be reused.
         // If there is a block already created, change its width.
         if (prevFrame && prevFrame.srcData.rawLocation == location) {
           prevFrame.width = (time - prevFrame.srcData.startTime);
         }
@@ -890,16 +916,32 @@ let FlameGraphUtils = {
     for (let [color, blocks] of buckets) {
       out.push({ color, blocks });
     }
 
     return out;
   },
 
   /**
+   * Checks if the provided frame is the same as the next one in a sample.
+   *
+   * @param object e
+   *        An object containing a { location } property.
+   * @param number index
+   *        The index of the object in the parent array.
+   * @param array array
+   *        The parent array.
+   * @return boolean
+   *         True if the next frame shares the same location, false otherwise.
+   */
+  _isConsecutiveDuplicate: function(e, index, array) {
+    return index < array.length - 1 && e.location != array[index + 1].location;
+  },
+
+  /**
    * Very dumb hashing of a string. Used to pick colors from a pallette.
    *
    * @param string input
    * @return number
    */
   _getStringHash: function(input) {
     const STRING_HASH_PRIME1 = 7;
     const STRING_HASH_PRIME2 = 31;
--- a/browser/devtools/shared/widgets/VariablesView.jsm
+++ b/browser/devtools/shared/widgets/VariablesView.jsm
@@ -2421,20 +2421,37 @@ Variable.prototype = Heritage.extend(Sco
       return;
     }
 
     let prevGrip = this._valueGrip;
     if (prevGrip) {
       this._valueLabel.classList.remove(VariablesView.getClass(prevGrip));
     }
     this._valueGrip = aGrip;
-    this._valueString = VariablesView.getString(aGrip, {
-      concise: true,
-      noEllipsis: true,
-    });
+
+    if(aGrip && (aGrip.optimizedOut || aGrip.uninitialized || aGrip.missingArguments)) {
+      if(aGrip.optimizedOut) {
+        this._valueString = STR.GetStringFromName("variablesViewOptimizedOut")
+      }
+      else if(aGrip.uninitialized) {
+        this._valueString = STR.GetStringFromName("variablesViewUninitialized")
+      }
+      else if(aGrip.missingArguments) {
+        this._valueString = STR.GetStringFromName("variablesViewMissingArgs")
+      }
+      this.eval = null;
+    }
+    else {
+      this._valueString = VariablesView.getString(aGrip, {
+        concise: true,
+        noEllipsis: true,
+      });
+      this.eval = this.ownerView.eval;
+    }
+
     this._valueClassName = VariablesView.getClass(aGrip);
 
     this._valueLabel.classList.add(this._valueClassName);
     this._valueLabel.setAttribute("value", this._valueString);
     this._separatorLabel.hidden = false;
 
     // DOMNodes get special treatment since they can be linked to the inspector
     if (this._valueGrip.preview && this._valueGrip.preview.kind === "DOMNode") {
--- a/browser/locales/en-US/chrome/browser/devtools/debugger.properties
+++ b/browser/locales/en-US/chrome/browser/devtools/debugger.properties
@@ -311,9 +311,13 @@ functionSearchSeparatorLabel=←
 
 # LOCALIZATION NOTE (resumptionOrderPanelTitle): This is the text that appears
 # as a description in the notification panel popup, when multiple debuggers are
 # open in separate tabs and the user tries to resume them in the wrong order.
 # The substitution parameter is the URL of the last paused window that must be
 # resumed first.
 resumptionOrderPanelTitle=There are one or more paused debuggers. Please resume the most-recently paused debugger first at: %S
 
+variablesViewOptimizedOut=(optimized away)
+variablesViewUninitialized=(uninitialized)
+variablesViewMissingArgs=(unavailable)
+
 evalGroupLabel=Evaluated Sources
\ No newline at end of file
--- a/browser/locales/en-US/chrome/browser/devtools/profiler.properties
+++ b/browser/locales/en-US/chrome/browser/devtools/profiler.properties
@@ -82,16 +82,20 @@ category.network=Network
 category.graphics=Graphics
 category.storage=Storage
 category.events=Input & Events
 
 # LOCALIZATION NOTE (table.root):
 # This string is displayed in the call tree for the root node.
 table.root=(root)
 
+# LOCALIZATION NOTE (table.idle):
+# This string is displayed in the call tree for the idle blocks.
+table.idle=(idle)
+
 # LOCALIZATION NOTE (table.url.tooltiptext):
 # This string is displayed in the call tree as the tooltip text for the url
 # labels which, when clicked, jump to the debugger.
 table.url.tooltiptext=View source in Debugger
 
 # LOCALIZATION NOTE (table.zoom.tooltiptext):
 # This string is displayed in the call tree as the tooltip text for the 'zoom'
 # buttons (small magnifying glass icons) which spawn a new tab.
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -8,40 +8,18 @@
 ## should remain "Firefox Hello" for all locales.
 clientShortname2=Firefox Hello
 
 ## LOCALIZATION_NOTE(first_time_experience.title): clientShortname will be
 ## replaced by the brand name
 first_time_experience_title={{clientShortname}} — Join the conversation
 first_time_experience_button_label=Get Started
 
-share_link_header_text=Share this link to invite someone to talk:
 invite_header_text=Invite someone to join you.
 
-## LOCALIZATION NOTE(invitee_name_label): Displayed when obtaining a url.
-## See https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#precall-firstrun
-## Click the label icon at the end of the url field.
-invitee_name_label=Who are you inviting?
-## LOCALIZATION NOTE(invitee_expire_days_label): Allows the user to adjust
-## the expiry time. Click the label icon at the end of the url field to see where
-## this is:
-## https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#precall-firstrun
-## Semicolon-separated list of plural forms. See:
-## http://developer.mozilla.org/en/docs/Localization_and_Plurals
-## In this item, don't translate the part between {{..}}
-invitee_expire_days_label=Invitation will expire in {{expiry_time}} day;Invitation will expire in {{expiry_time}} days
-## LOCALIZATION NOTE(invitee_expire_hours_label): Allows the user to adjust
-## the expiry time. Click the label icon are the end of the url field to see where
-## this is:
-## https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#precall-firstrun
-## Semicolon-separated list of plural forms. See:
-## http://developer.mozilla.org/en/docs/Localization_and_Plurals
-## In this item, don't translate the part between {{..}}
-invitee_expire_hours_label=Invitation will expire in {{expiry_time}} hour;Invitation will expire in {{expiry_time}} hours
-
 # Status text
 display_name_guest=Guest
 display_name_dnd_status=Do Not Disturb
 display_name_available_status=Available
 
 # Error bars
 ## LOCALIZATION NOTE(unable_retrieve_url,session_expired_error_description,could_not_authenticate,password_changed_question,try_again_later,could_not_connect,check_internet_connection,login_expired,service_not_available,problem_accessing_account):
 ## These may be displayed at the top of the panel here:
@@ -61,19 +39,17 @@ problem_accessing_account=There Was A Pr
 ## the appropriate action.
 ## See https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#error for location
 retry_button=Retry
 
 share_email_subject4={{clientShortname}} — Join the conversation
 ## LOCALIZATION NOTE (share_email_body4): In this item, don't translate the
 ## part between {{..}} and leave the \r\n\r\n part alone
 share_email_body4=Hello!\r\n\r\nJoin me for a video conversation using {{clientShortname}}:\r\n\r\nYou don't have to download or install anything. Just copy and paste this URL into your browser:\r\n\r\n{{callUrl}}\r\n\r\nIf you want, you can also learn more about {{clientShortname}} at {{learnMoreUrl}}\r\n\r\nTalk to you soon!
-share_button=Email
 share_button2=Email Link
-copy_url_button=Copy
 copy_url_button2=Copy Link
 copied_url_button=Copied!
 
 panel_footer_signin_or_signup_link=Sign In or Sign Up
 
 settings_menu_item_account=Account
 settings_menu_item_settings=Settings
 settings_menu_item_signout=Sign Out
@@ -115,16 +91,23 @@ valid_email_text_description=Please ente
 ## LOCALIZATION NOTE (add_or_import_contact_title): This is the subtitle of the panel
 ## at https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#contacts
 add_or_import_contact_title=Add or Import Contact
 ## LOCALIZATION NOTE (import_contacts_button, importing_contacts_progress_button):
 ## See https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#contacts
 ## for where these appear on the UI
 import_contacts_button=Import
 importing_contacts_progress_button=Importing…
+import_contacts_failure_message=Some contacts could not be imported. Please try again.
+## LOCALIZATION NOTE(import_contacts_success_message): Success notification message
+## when user's contacts have been successfully imported.
+## Semicolon-separated list of plural forms. See:
+## http://developer.mozilla.org/en/docs/Localization_and_Plurals
+## In this item, don't translate the part between {{..}}
+import_contacts_success_message={{total}} contact was successfully imported.;{{total}} contacts were successfully imported.
 ## LOCALIZATION NOTE(sync_contacts_button): This button is displayed in place of
 ## importing_contacts_button once contacts have been imported once.
 sync_contacts_button=Sync Contacts
 
 ## LOCALIZATION NOTE(import_failed_description simple): Displayed when an import of
 ## contacts fails. This is displayed in the error field here:
 ## https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#error
 import_failed_description_simple=Sorry, contact import failed
--- a/browser/modules/ContentWebRTC.jsm
+++ b/browser/modules/ContentWebRTC.jsm
@@ -22,23 +22,31 @@ this.ContentWebRTC = {
       return;
 
     this._initialized = true;
     Services.obs.addObserver(handleRequest, "getUserMedia:request", false);
     Services.obs.addObserver(updateIndicators, "recording-device-events", false);
     Services.obs.addObserver(removeBrowserSpecificIndicator, "recording-window-ended", false);
   },
 
+  // Called only for 'unload' to remove pending gUM prompts in reloaded frames.
+  handleEvent: function(aEvent) {
+    let contentWindow = aEvent.target.defaultView;
+    let mm = getMessageManagerForWindow(contentWindow);
+    for (let key of contentWindow.pendingGetUserMediaRequests.keys())
+      mm.sendAsyncMessage("webrtc:CancelRequest", key);
+  },
+
   receiveMessage: function(aMessage) {
     switch (aMessage.name) {
       case "webrtc:Allow":
         let callID = aMessage.data.callID;
         let contentWindow = Services.wm.getOuterWindowWithId(aMessage.data.windowID);
         let devices = contentWindow.pendingGetUserMediaRequests.get(callID);
-        contentWindow.pendingGetUserMediaRequests.delete(callID);
+        forgetRequest(contentWindow, callID);
 
         let allowedDevices = Cc["@mozilla.org/supports-array;1"]
                                .createInstance(Ci.nsISupportsArray);
         for (let deviceIndex of aMessage.data.devices)
            allowedDevices.AppendElement(devices[deviceIndex]);
 
         Services.obs.notifyObservers(allowedDevices, "getUserMedia:response:allow", callID);
         break;
@@ -107,18 +115,20 @@ function prompt(aContentWindow, aWindowI
   if (audioDevices.length)
     requestTypes.push("Microphone");
 
   if (!requestTypes.length) {
     denyRequest({callID: aCallID}, "NotFoundError");
     return;
   }
 
-  if (!aContentWindow.pendingGetUserMediaRequests)
+  if (!aContentWindow.pendingGetUserMediaRequests) {
     aContentWindow.pendingGetUserMediaRequests = new Map();
+    aContentWindow.addEventListener("unload", ContentWebRTC);
+  }
   aContentWindow.pendingGetUserMediaRequests.set(aCallID, devices);
 
   let request = {
     callID: aCallID,
     windowID: aWindowID,
     documentURI: aContentWindow.document.documentURI,
     secure: aSecure,
     requestTypes: requestTypes,
@@ -138,19 +148,27 @@ function denyRequest(aData, aError) {
     msg.data = aError;
   }
   Services.obs.notifyObservers(msg, "getUserMedia:response:deny", aData.callID);
 
   if (!aData.windowID)
     return;
   let contentWindow = Services.wm.getOuterWindowWithId(aData.windowID);
   if (contentWindow.pendingGetUserMediaRequests)
-    contentWindow.pendingGetUserMediaRequests.delete(aData.callID);
+    forgetRequest(contentWindow, aData.callID);
 }
 
+function forgetRequest(aContentWindow, aCallID) {
+  aContentWindow.pendingGetUserMediaRequests.delete(aCallID);
+  if (aContentWindow.pendingGetUserMediaRequests.size)
+    return;
+
+  aContentWindow.removeEventListener("unload", ContentWebRTC);
+  aContentWindow.pendingGetUserMediaRequests = null;
+}
 
 function updateIndicators() {
   let contentWindowSupportsArray = MediaManagerService.activeMediaCaptureWindows;
   let count = contentWindowSupportsArray.Count();
 
   let state = {
     showGlobalIndicator: count > 0,
     showCameraIndicator: false,
--- a/browser/modules/webrtcUI.jsm
+++ b/browser/modules/webrtcUI.jsm
@@ -23,30 +23,32 @@ this.webrtcUI = {
     let ppmm = Cc["@mozilla.org/parentprocessmessagemanager;1"]
                  .getService(Ci.nsIMessageBroadcaster);
     ppmm.addMessageListener("webrtc:UpdatingIndicators", this);
     ppmm.addMessageListener("webrtc:UpdateGlobalIndicators", this);
 
     let mm = Cc["@mozilla.org/globalmessagemanager;1"]
                .getService(Ci.nsIMessageListenerManager);
     mm.addMessageListener("webrtc:Request", this);
+    mm.addMessageListener("webrtc:CancelRequest", this);
     mm.addMessageListener("webrtc:UpdateBrowserIndicators", this);
   },
 
   uninit: function () {
     Services.obs.removeObserver(maybeAddMenuIndicator, "browser-delayed-startup-finished");
 
     let ppmm = Cc["@mozilla.org/parentprocessmessagemanager;1"]
                  .getService(Ci.nsIMessageBroadcaster);
     ppmm.removeMessageListener("webrtc:UpdatingIndicators", this);
     ppmm.removeMessageListener("webrtc:UpdateGlobalIndicators", this);
 
     let mm = Cc["@mozilla.org/globalmessagemanager;1"]
                .getService(Ci.nsIMessageListenerManager);
     mm.removeMessageListener("webrtc:Request", this);
+    mm.removeMessageListener("webrtc:CancelRequest", this);
     mm.removeMessageListener("webrtc:UpdateBrowserIndicators", this);
   },
 
   showGlobalIndicator: false,
   showCameraIndicator: false,
   showMicrophoneIndicator: false,
   showScreenSharingIndicator: "", // either "Application", "Screen" or "Window"
 
@@ -120,16 +122,19 @@ this.webrtcUI = {
     popupnotification.setAttribute("buttonlabel", bundle.getString(stringId));
   },
 
   receiveMessage: function(aMessage) {
     switch (aMessage.name) {
       case "webrtc:Request":
         prompt(aMessage.target, aMessage.data);
         break;
+      case "webrtc:CancelRequest":
+        removePrompt(aMessage.target, aMessage.data);
+        break;
       case "webrtc:UpdatingIndicators":
         webrtcUI._streams = [];
         break;
       case "webrtc:UpdateGlobalIndicators":
         updateIndicators(aMessage.data)
         break;
       case "webrtc:UpdateBrowserIndicators":
         webrtcUI._streams.push({browser: aMessage.target, state: aMessage.data});
@@ -161,17 +166,19 @@ function getHost(uri, href) {
     host = uri.host;
   } catch (ex) {};
   if (!host) {
     if (uri && uri.scheme.toLowerCase() == "about") {
       // For about URIs, just use the full spec, without any #hash parts
       host = uri.specIgnoringRef;
     } else {
       // This is unfortunate, but we should display *something*...
-      host = bundle.getString("getUserMedia.sharingMenuUnknownHost");
+      const kBundleURI = "chrome://browser/locale/browser.properties";
+      let bundle = Services.strings.createBundle(kBundleURI);
+      host = bundle.GetStringFromName("getUserMedia.sharingMenuUnknownHost");
     }
   }
   return host;
 }
 
 function prompt(aBrowser, aRequest) {
   let {audioDevices: audioDevices, videoDevices: videoDevices,
        sharingScreen: sharingScreen, requestTypes: requestTypes} = aRequest;
@@ -430,16 +437,25 @@ function prompt(aBrowser, aRequest) {
   if (requestTypes.length == 1 && requestTypes[0] == "Microphone")
     anchorId = "webRTC-shareMicrophone-notification-icon";
   if (requestTypes.indexOf("Screen") != -1)
     anchorId = "webRTC-shareScreen-notification-icon";
   notification =
     chromeWin.PopupNotifications.show(aBrowser, "webRTC-shareDevices", message,
                                       anchorId, mainAction, secondaryActions,
                                       options);
+  notification.callID = aRequest.callID;
+}
+
+function removePrompt(aBrowser, aCallId) {
+  let chromeWin = aBrowser.ownerDocument.defaultView;
+  let notification =
+    chromeWin.PopupNotifications.getNotification("webRTC-shareDevices", aBrowser);
+  if (notification && notification.callID == aCallId)
+    notification.remove();
 }
 
 function getGlobalIndicator() {
 #ifndef XP_MACOSX
   const INDICATOR_CHROME_URI = "chrome://browser/content/webrtcIndicator.xul";
   const features = "chrome,dialog=yes,titlebar=no,popup=yes";
 
   return Services.ww.openWindow(null, INDICATOR_CHROME_URI, "_blank", features, []);
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..eb6f5f653e7c1c39315aaa377ccec0e9a4a223df
GIT binary patch
literal 139
zc%17D@N?(olHy`uVBq!ia0vp^Y9P$P3?%12mYf5m_yc@GTp7UN_3IChW@!BX|9?_;
z&p9BUu_VYZn8D%MjWi%f%hSa%q=GT|0F%T<P9s4^3C$q0RXHIyS8NJQ?O}domZo&T
fK{Hu}$$)`@-I43}8>ZrIATvE({an^LB{Ts5)WRpb
deleted file mode 100644
index 684d6f3e70594cfc18984d379a4b95268009d0c5..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..bad01d12080ae8c7f98c5691627160a9f4f1d22c
GIT binary patch
literal 131
zc%17D@N?(olHy`uVBq!ia0vp^QXtI13?%1G+4BcT@dx;XxH5pj>(?J1&CvM&|No@y
zo^wDxV@Z%-FoVOh8)-m}lBbJfNCjiE17pvM7&V!O!Vb?~FT+chCC^we+zjfmU|?oQ
WlV<syr%<H`GRxD|&t;ucLK6V<T_zs@
deleted file mode 100644
index 0b82d97360feff832064db31e520fdca4a947c65..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..4ab8b40a1912b22050d1c57abc430411679f074d
GIT binary patch
literal 146
zc%17D@N?(olHy`uVBq!ia0vp^Mj*_=3?wxlRx|@C{s5m4R|YV6{rbbB85;lp|DTlI
za}LO7ED7=pW^j0RBMrzg^mK6yskrs_{6<~|1`a02i+{_@n7FNk{zn(FmM*K#GImrD
m;9y~@bP@TTdS+7RnJh-7eZtDO`^)!&jP`W(b6Mw<&;$T+j4c!Z
deleted file mode 100644
index 8d0e77b75acd0eb2a4aad741d8e08b6b272a764c..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
--- a/mobile/android/base/resources/values-v11/styles.xml
+++ b/mobile/android/base/resources/values-v11/styles.xml
@@ -72,17 +72,17 @@
         <item name="android:minWidth">@dimen/doorhanger_input_width</item>
     </style>
 
     <style name="Widget.TextView.SpinnerItem" parent="android:style/Widget.Holo.Light.TextView.SpinnerItem">
         <item name="android:textColor">#FF000000</item>
     </style>
 
     <style name="GeckoActionBar" parent="@android:style/Widget.Holo.Light.ActionMode">
-        <item name="android:background">@drawable/ab_stacked_transparent_light_holo</item>
+        <item name="android:background">@drawable/ab_background</item>
     </style>
 
     <style name="TextAppearance.Widget.ActionBar.Title" parent="@android:style/TextAppearance.Medium"/>
 
     <style name="GeckoActionBar.Title" parent="TextAppearance.Widget.ActionBar.Title">
         <item name="android:drawableLeft">@drawable/ab_done</item>
         <item name="android:background">@android:color/transparent</item>
         <item name="android:paddingLeft">15dp</item>
--- a/mobile/android/base/resources/values/styles.xml
+++ b/mobile/android/base/resources/values/styles.xml
@@ -746,17 +746,17 @@
     </style>
 
     <style name="ToastButton" parent="ToastButtonBase">
         <item name="android:textAppearance">?android:textAppearanceSmall</item>
         <item name="android:textStyle">bold</item>
     </style>
 
     <style name="GeckoActionBar">
-        <item name="android:background">@drawable/ab_stacked_transparent_light_holo</item>
+        <item name="android:background">@drawable/ab_background</item>
     </style>
 
     <style name="GeckoActionBar.Title">
         <item name="android:gravity">center_vertical</item>
         <item name="android:minWidth">0dp</item>
         <item name="android:background">@android:color/transparent</item>
         <item name="android:textAppearance">@style/TextAppearance.Medium</item>
         <item name="android:drawableLeft">@drawable/ab_done</item>
--- a/toolkit/components/asyncshutdown/AsyncShutdown.jsm
+++ b/toolkit/components/asyncshutdown/AsyncShutdown.jsm
@@ -41,16 +41,18 @@
 const Cu = Components.utils;
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
 Cu.import("resource://gre/modules/Services.jsm", this);
 
 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
   "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
+  "resource://gre/modules/PromiseUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
   "resource://gre/modules/Task.jsm");
 XPCOMUtils.defineLazyServiceGetter(this, "gDebug",
   "@mozilla.org/xpcom/debug;1", "nsIDebug");
 Object.defineProperty(this, "gCrashReporter", {
   get: function() {
     delete this.gCrashReporter;
     try {
@@ -76,16 +78,112 @@ try {
   DELAY_CRASH_MS = Services.prefs.getIntPref(PREF_DELAY_CRASH_MS);
 } catch (ex) {
   // Ignore errors
 }
 Services.prefs.addObserver(PREF_DELAY_CRASH_MS, function() {
   DELAY_CRASH_MS = Services.prefs.getIntPref(PREF_DELAY_CRASH_MS);
 }, false);
 
+/**
+ * A set of Promise that supports waiting.
+ *
+ * Promise items may be added or removed during the wait. The wait will
+ * resolve once all Promise items have been resolved or removed.
+ */
+function PromiseSet() {
+  /**
+   * key: the Promise passed pass the client of the `PromiseSet`.
+   * value: an indirection on top of `key`, as an object with
+   *   the following fields:
+   *   - indirection: a Promise resolved if `key` is resolved or
+   *     if `resolve` is called
+   *   - resolve: a function used to resolve the indirection.
+   */
+  this._indirections = new Map();
+}
+PromiseSet.prototype = {
+  /**
+   * Wait until all Promise have been resolved or removed.
+   *
+   * Note that calling `wait()` causes Promise to be removed from the
+   * Set once they are resolved.
+   *
+   * @return {Promise} Resolved once all Promise have been resolved or removed,
+   * or rejected after at least one Promise has rejected.
+   */
+  wait: function() {
+    // Pick an arbitrary element in the map, if any exists.
+    let entry = this._indirections.entries().next();
+    if (entry.done) {
+      // No indirections left, we are done.
+      return Promise.resolve();
+    }
+
+    let [, indirection] = entry.value;
+    let promise = indirection.promise;
+    promise = promise.then(() =>
+      // At this stage, the entry has been cleaned up.
+      this.wait()
+    );
+    return promise;
+  },
+
+  /**
+   * Add a new Promise to the set.
+   *
+   * Calls to wait (including ongoing calls) will only return once
+   * `key` has either resolved or been removed.
+   */
+  add: function(key) {
+    this._ensurePromise(key);
+    let indirection = PromiseUtils.defer();
+    key.then(
+      x => {
+        // Clean up immediately.
+        // This needs to be done before the call to `resolve`, otherwise
+        // `wait()` may loop forever.
+        this._indirections.delete(key);
+        indirection.resolve(x);
+      },
+      err => {
+        this._indirections.delete(key);
+        indirection.reject(err);
+      });
+    this._indirections.set(key, indirection);
+  },
+
+  /**
+   * Remove a Promise from the set.
+   *
+   * Calls to wait (including ongoing calls) will ignore this promise,
+   * unless it is added again.
+   */
+  delete: function(key) {
+    this._ensurePromise(key);
+    let value = this._indirections.get(key);
+    if (!value) {
+      return false;
+    }
+    this._indirections.delete(key);
+    value.resolve();
+    return true;
+  },
+
+  _ensurePromise: function(key) {
+    if (!key || typeof key != "object") {
+      throw new Error("Expected an object");
+    }
+    if ((!"then" in key) || typeof key.then != "function") {
+      throw new Error("Expected a Promise");
+    }
+  },
+
+};
+
 
 /**
  * Display a warning.
  *
  * As this code is generally used during shutdown, there are chances
  * that the UX will not be available to display warnings on the
  * console. We therefore use dump() rather than Cu.reportError().
  */
@@ -386,48 +484,69 @@ Spinner.prototype = {
  * @param {string} name The name of the blocker. Used mainly for error-
  *     reporting.
  */
 function Barrier(name) {
   if (!name) {
     throw new TypeError("Instances of Barrier need a (non-empty) name");
   }
 
+
   /**
-   * The set of conditions registered by clients, as a map.
+   * The set of all Promise for which we need to wait before the barrier
+   * is lifted. Note that this set may be changed while we are waiting.
    *
-   * Key: condition (function)
-   * Value: Array of {name: string, fetchState: function, filename: string,
-   *   lineNumber: number, stack: string}
+   * Set to `null` once the wait is complete.
    */
-  this._conditions = new Map();
+  this._waitForMe = new PromiseSet();
 
   /**
-   * Indirections, used to let clients cancel a blocker when they
-   * call removeBlocker().
+   * A map from conditions, as passed by users during the call to `addBlocker`,
+   * to `promise`, as present in `this._waitForMe`.
+   *
+   * Used to let users perform cleanup through `removeBlocker`.
+   * Set to `null` once the wait is complete.
    *
-   * Key: condition (function)
-   * Value: Deferred.
+   * Key: condition (any, as passed by user)
+   * Value: promise used as a key in `this._waitForMe`. Note that there is
+   * no guarantee that the key is still present in `this._waitForMe`.
    */
-  this._indirections = null;
+  this._conditionToPromise = new Map();
+
+  /**
+   * A map from Promise, as present in `this._waitForMe` or
+   * `this._conditionToPromise`, to information on blockers.
+   *
+   * Key: Promise (as present in this._waitForMe or this._conditionToPromise).
+   * Value:  {
+   *  trigger: function,
+   *  promise,
+   *  name,
+   *  fetchState: function,
+   *  stack,
+   *  filename,
+   *  lineNumber
+   * };
+   */
+  this._promiseToBlocker = new Map();
 
   /**
    * The name of the barrier.
    */
   this._name = name;
 
   /**
    * A cache for the promise returned by wait().
    */
   this._promise = null;
 
   /**
-   * An array of objects used to monitor the state of each blocker.
+   * `true` once we have started waiting.
    */
-  this._monitors = null;
+  this._isStarted = false;
 
   /**
    * The capability of adding blockers. This object may safely be returned
    * or passed to clients.
    */
   this.client = {
     /**
      * The name of the barrier owning this client.
@@ -460,36 +579,36 @@ function Barrier(name) {
      * - stack. A string containing stack information. This module can
      *    generally infer stack information if it is not provided.
      * - lineNumber A number containing the line number for the caller.
      *    This module can generally infer this information if it is not
      *    provided.
      * - filename A string containing the filename for the caller. This
      *    module can generally infer  the information if it is not provided.
      */
-    addBlocker: function(name, condition, details) {
+    addBlocker: (name, condition, details) => {
       if (typeof name != "string") {
         throw new TypeError("Expected a human-readable name as first argument");
       }
       if (details && typeof details == "function") {
         details = {
           fetchState: details
         };
       } else if (!details) {
         details = {};
       }
       if (typeof details != "object") {
         throw new TypeError("Expected an object as third argument to `addBlocker`, got " + details);
       }
-      if (!this._conditions) {
-	throw new Error("Phase " + this._name +
-			" has already begun, it is too late to register" +
-			" completion condition '" + name + "'.");
+      if (!this._waitForMe) {
+        throw new Error(`Phase "${ this._name } is finished, it is too late to register completion condition "${ name }"`);
       }
 
+      // Normalize the details
+
       let fetchState = details.fetchState || null;
       let filename = details.filename || "?";
       let lineNumber = details.lineNumber || -1;
       let stack = details.stack || undefined;
 
       if (filename == "?" || lineNumber == -1 || stack === undefined) {
         // Determine the filename and line number of the caller.
         let leaf = Components.stack;
@@ -512,79 +631,120 @@ function Barrier(name) {
           frames.push(frame.filename + ":" + frame.name + ":" + frame.lineNumber);
           frame = frame.caller;
         }
         if (stack === undefined) {
           stack = Task.Debugging.generateReadableStack(frames.join("\n")).split("\n");
         }
       }
 
-      let set = this._conditions.get(condition);
-      if (!set) {
-        set = [];
-        this._conditions.set(condition, set);
+      // Split the condition between a trigger function and a promise.
+
+      // The function to call to notify the blocker that we have started waiting.
+      // This function returns a promise resolved/rejected once the
+      // condition is complete, and never throws.
+      let trigger;
+
+      // A promise resolved once the condition is complete.
+      let promise;
+      if (typeof condition == "function") {
+        promise = new Promise((resolve, reject) => {
+          trigger = () => {
+            try {
+              resolve(condition());
+            } catch (ex) {
+              reject(ex);
+            }
+          }
+        });
+      } else {
+        // If `condition` is not a function, `trigger` is not particularly
+        // interesting, and `condition` needs to be normalized to a promise.
+        trigger = () => {};
+        promise = Promise.resolve(condition);
       }
-      set.push({name: name,
-                fetchState: fetchState,
-                filename: filename,
-                lineNumber: lineNumber,
-                stack: stack});
-    }.bind(this),
+
+      // Make sure that `promise` never rejects.
+      promise = promise.then(null, error => {
+        let msg = `A blocker encountered an error while we were waiting.
+          Blocker:  ${ name }
+          Phase: ${ this._name }
+          State: ${ safeGetState(fetchState) }`;
+        warn(msg, error);
+
+        // The error should remain uncaught, to ensure that it
+        // still causes tests to fail.
+        Promise.reject(error);
+      });
+
+      let blocker = {
+        trigger: trigger,
+        promise: promise,
+        name: name,
+        fetchState: fetchState,
+        stack: stack,
+        filename: filename,
+        lineNumber: lineNumber
+      };
+
+      this._waitForMe.add(promise);
+      this._promiseToBlocker.set(promise, blocker);
+      this._conditionToPromise.set(condition, promise);
+
+      // As conditions may hold lots of memory, we attempt to cleanup
+      // as soon as we are done (which might be in the next tick, if
+      // we have been passed a resolved promise).
+      promise = promise.then(() =>
+        this._removeBlocker(condition)
+      );
+
+      if (this._isStarted) {
+        // The wait has already started. The blocker should be
+        // notified asap. We do it out of band as clients probably
+        // expect `addBlocker` to return immediately.
+        Promise.resolve().then(trigger);
+      }
+    },
 
     /**
      * Remove the blocker for a condition.
      *
      * If several blockers have been registered for the same
      * condition, remove all these blockers. If no blocker has been
      * registered for this condition, this is a noop.
      *
      * @return {boolean} true if at least one blocker has been
      * removed, false otherwise.
      */
-    removeBlocker: function(condition) {
-      if (this._conditions) {
-        // wait() hasn't been called yet.
-        return this._conditions.delete(condition);
-      }
-
-      if (this._indirections) {
-        // wait() is in progress
-        let deferred = this._indirections.get(condition);
-        if (deferred) {
-          // Unlock the blocker
-          deferred.resolve();
-        }
-        return this._indirections.delete(condition);
-      }
-      // wait() is complete.
-      return false;
-    }.bind(this),
+    removeBlocker: (condition) => {
+      return this._removeBlocker(condition);
+    }
   };
 }
 Barrier.prototype = Object.freeze({
   /**
    * The current state of the barrier, as a JSON-serializable object
    * designed for error-reporting.
    */
   get state() {
-    if (this._conditions) {
+    if (!this._isStarted) {
       return "Not started";
     }
-    if (!this._monitors) {
+    if (!this._waitForMe) {
       return "Complete";
     }
     let frozen = [];
-    for (let {name, isComplete, fetchState, stack, filename, lineNumber} of this._monitors) {
-      if (!isComplete) {
-        frozen.push({name: name,
-                     state: safeGetState(fetchState),
-                     filename: filename,
-                     lineNumber: lineNumber,
-                     stack: stack});
-      }
+    for (let {name, fetchState, stack, filename, lineNumber} of this._promiseToBlocker.values()) {
+      frozen.push({
+        name: name,
+        state: safeGetState(fetchState),
+        filename: filename,
+        lineNumber: lineNumber,
+        stack: stack
+      });
     }
     return frozen;
   },
 
   /**
    * Wait until all currently registered blockers are complete.
    *
    * Once this method has been called, any attempt to register a new blocker
@@ -609,140 +769,74 @@ Barrier.prototype = Object.freeze({
   wait: function(options = {}) {
     // This method only implements caching on top of _wait()
     if (this._promise) {
       return this._promise;
     }
     return this._promise = this._wait(options);
   },
   _wait: function(options) {
-    let topic = this._name;
-    let conditions = this._conditions;
-    this._conditions = null; // Too late to register
-    if (conditions.size == 0) {
-      return Promise.resolve();
+
+    // Sanity checks
+    if (this._isStarted) {
+      throw new TypeError("Internal error: already started " + this._name);
+    }
+    if (!this._waitForMe || !this._conditionToPromise || !this._promiseToBlocker) {
+      throw new TypeError("Internal error: already finished " + this._name);
     }
 
-    this._indirections = new Map();
-    // The promises for which we are waiting.
-    let allPromises = [];
-
-    // Information to determine and report to the user which conditions
-    // are not satisfied yet.
-    this._monitors = [];
-
-    for (let _condition of conditions.keys()) {
-      for (let current of conditions.get(_condition)) {
-        let condition = _condition; // Avoid capturing the wrong variable
-        let {name, fetchState, stack, filename, lineNumber} = current;
-
-        // An indirection on top of condition, used to let clients
-        // cancel a blocker through removeBlocker.
-        let indirection = Promise.defer();
-        this._indirections.set(condition, indirection);
-
-        // Gather all completion conditions
-
-        try {
-          if (typeof condition == "function") {
-            // Normalize |condition| to the result of the function.
-            try {
-              condition = condition(topic);
-            } catch (ex) {
-              condition = Promise.reject(ex);
-            }
-          }
-
-          // Normalize to a promise. Of course, if |condition| was not a
-          // promise in the first place (in particular if the above
-          // function returned |undefined| or failed), that new promise
-          // isn't going to be terribly interesting, but it will behave
-          // as a promise.
-          condition = Promise.resolve(condition);
+    let topic = this._name;
 
-          let monitor = {
-            isComplete: false,
-            name: name,
-            fetchState: fetchState,
-            stack: stack,
-            filename: filename,
-            lineNumber: lineNumber
-          };
-
-	  condition = condition.then(null, function onError(error) {
-            let msg = "A completion condition encountered an error" +
-              " while we were spinning the event loop." +
-	      " Condition: " + name +
-              " Phase: " + topic +
-              " State: " + safeGetState(fetchState);
-	    warn(msg, error);
+    // Notify blockers
+    for (let blocker of this._promiseToBlocker.values()) {
+      blocker.trigger(); // We have guarantees that this method will never throw
+    }
 
-            // The error should remain uncaught, to ensure that it
-            // still causes tests to fail.
-            Promise.reject(error);
-	  });
-          condition.then(() => indirection.resolve());
-
-          indirection.promise.then(() => monitor.isComplete = true);
-          this._monitors.push(monitor);
-          allPromises.push(indirection.promise);
+    this._isStarted = true;
 
-        } catch (error) {
-            let msg = "A completion condition encountered an error" +
-                  " while we were initializing the phase." +
-                  " Condition: " + name +
-                  " Phase: " + topic +
-                  " State: " + safeGetState(fetchState);
-            warn(msg, error);
-        }
-
-      }
-    }
-    conditions = null;
-
-    let promise = Promise.all(allPromises);
-    allPromises = null;
+    // Now, wait
+    let promise = this._waitForMe.wait();
 
     promise = promise.then(null, function onError(error) {
       // I don't think that this can happen.
       // However, let's be overcautious with async/shutdown error reporting.
       let msg = "An uncaught error appeared while completing the phase." +
-            " Phase: " + topic;
+        " Phase: " + topic;
       warn(msg, error);
     });
 
     promise = promise.then(() => {
-      this._monitors = null;
-      this._indirections = null;
-    }); // Memory cleanup
-
+      // Cleanup memory
+      this._waitForMe = null;
+      this._promiseToBlocker = null;
+      this._conditionToPromise = null;
+    });
 
     // Now handle warnings and crashes
-
     let warnAfterMS = DELAY_WARNING_MS;
     if (options && "warnAfterMS" in options) {
       if (typeof options.warnAfterMS == "number"
          || options.warnAfterMS == null) {
         // Change the delay or deactivate warnAfterMS
         warnAfterMS = options.warnAfterMS;
       } else {
         throw new TypeError("Wrong option value for warnAfterMS");
       }
     }
 
     if (warnAfterMS && warnAfterMS > 0) {
       // If the promise takes too long to be resolved/rejected,
       // we need to notify the user.
       let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
-      timer.initWithCallback(function() {
+      timer.initWithCallback(() => {
         let msg = "At least one completion condition is taking too long to complete." +
-	  " Conditions: " + JSON.stringify(this.state) +
-	  " Barrier: " + topic;
+        " Conditions: " + JSON.stringify(this.state) +
+        " Barrier: " + topic;
         warn(msg);
-      }.bind(this), warnAfterMS, Ci.nsITimer.TYPE_ONE_SHOT);
+      }, warnAfterMS, Ci.nsITimer.TYPE_ONE_SHOT);
 
       promise = promise.then(function onSuccess() {
         timer.cancel();
         // As a side-effect, this prevents |timer| from
         // being garbage-collected too early.
       });
     }
 
@@ -764,70 +858,84 @@ Barrier.prototype = Object.freeze({
       // account sleep and otherwise busy computer) we have not finished
       // this shutdown phase, we assume that the shutdown is somehow
       // frozen, presumably deadlocked. At this stage, the only thing we
       // can do to avoid leaving the user's computer in an unstable (and
       // battery-sucking) situation is report the issue and crash.
       timeToCrash = looseTimer(crashAfterMS);
       timeToCrash.promise.then(
         function onTimeout() {
-	  // Report the problem as best as we can, then crash.
-	  let state = this.state;
+          // Report the problem as best as we can, then crash.
+          let state = this.state;
 
           // If you change the following message, please make sure
           // that any information on the topic and state appears
           // within the first 200 characters of the message. This
           // helps automatically sort oranges.
           let msg = "AsyncShutdown timeout in " + topic +
             " Conditions: " + JSON.stringify(state) +
             " At least one completion condition failed to complete" +
-	    " within a reasonable amount of time. Causing a crash to" +
-	    " ensure that we do not leave the user with an unresponsive" +
-	    " process draining resources.";
-	  fatalerr(msg);
-	  if (gCrashReporter && gCrashReporter.enabled) {
+            " within a reasonable amount of time. Causing a crash to" +
+            " ensure that we do not leave the user with an unresponsive" +
+            " process draining resources.";
+          fatalerr(msg);
+          if (gCrashReporter && gCrashReporter.enabled) {
             let data = {
               phase: topic,
               conditions: state
-	    };
+            };
             gCrashReporter.annotateCrashReport("AsyncShutdownTimeout",
               JSON.stringify(data));
-	  } else {
+          } else {
             warn("No crash reporter available");
-	  }
+          }
 
           // To help sorting out bugs, we want to make sure that the
           // call to nsIDebug.abort points to a guilty client, rather
-          // than to AsyncShutdown itself. We search through all the
-          // clients until we find one that is guilty and use its
-          // filename/lineNumber, which have been determined during
-          // the call to `addBlocker`.
+          // than to AsyncShutdown itself. We pick a client that is
+          // still blocking and use its filename/lineNumber,
+          // which have been determined during the call to `addBlocker`.
           let filename = "?";
           let lineNumber = -1;
-          for (let monitor of this._monitors) {
-            if (monitor.isComplete) {
-              continue;
-            }
-            filename = monitor.filename;
-            lineNumber = monitor.lineNumber;
+          for (let blocker of this._promiseToBlocker) {
+            filename = blocker.filename;
+            lineNumber = blocker.lineNumber;
+            break;
           }
-	  gDebug.abort(filename, lineNumber);
+          gDebug.abort(filename, lineNumber);
         }.bind(this),
-	  function onSatisfied() {
-            // The promise has been rejected, which means that we have satisfied
-            // all completion conditions.
-          });
+        function onSatisfied() {
+          // The promise has been rejected, which means that we have satisfied
+          // all completion conditions.
+        });
 
       promise = promise.then(function() {
         timeToCrash.reject();
       }/* No error is possible here*/);
     }
 
     return promise;
   },
+
+  _removeBlocker: function(condition) {
+    if (!this._waitForMe || !this._promiseToBlocker || !this._conditionToPromise) {
+      // We have already cleaned up everything.
+      return false;
+    }
+
+    let promise = this._conditionToPromise.get(condition);
+    if (!promise) {
+      // The blocker has already been removed
+      return false;
+    }
+    this._conditionToPromise.delete(condition);
+    this._promiseToBlocker.delete(promise);
+    return this._waitForMe.delete(promise);
+  },
+
 });
 
 
 
 // List of well-known phases
 // Ideally, phases should be registered from the component that decides
 // when they start/stop. For compatibility with existing startup/shutdown
 // mechanisms, we register a few phases here.
--- a/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown.js
+++ b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown.js
@@ -1,49 +1,92 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 "use strict";
 
+Cu.import("resource://gre/modules/PromiseUtils.jsm", this);
+
 function run_test() {
   run_next_test();
 }
 
 add_task(function* test_no_condition() {
   for (let kind of ["phase", "barrier", "xpcom-barrier", "xpcom-barrier-unwrapped"]) {
     do_print("Testing a barrier with no condition (" + kind + ")");
     let lock = makeLock(kind);
     yield lock.wait();
     do_print("Barrier with no condition didn't lock");
   }
 });
 
-
 add_task(function* test_phase_various_failures() {
-  do_print("Ensure that we cannot add a condition for a phase once notification has been received");
   for (let kind of ["phase", "barrier", "xpcom-barrier", "xpcom-barrier-unwrapped"]) {
+    do_print("Kind: " + kind);
+    // Testing with wrong arguments
     let lock = makeLock(kind);
-    lock.wait(); // Don't actually wait for the promise to be resolved
-    let exn = get_exn(() => lock.addBlocker("Test", true));
-    do_check_true(!!exn);
+
+    Assert.throws(() => lock.addBlocker(), /TypeError|NS_ERROR_XPC_JAVASCRIPT_ERROR_WITH_DETAILS/);
+    Assert.throws(() => lock.addBlocker(null, true), /TypeError|NS_ERROR_XPC_JAVASCRIPT_ERROR_WITH_DETAILS/);
+
+    if (kind != "xpcom-barrier") {
+      // xpcom-barrier actually expects a string in that position
+      Assert.throws(() => lock.addBlocker("Test 2", () => true, "not a function"), /TypeError/);
+    }
+
+    // Attempting to add a blocker after we are done waiting
+    yield lock.wait();
+    Assert.throws(() => lock.addBlocker("Test 3", () => true), /is finished/);
+  }
+});
+
+add_task(function* test_reentrant() {
+  do_print("Ensure that we can call addBlocker from within a blocker");
+
+  for (let kind of ["phase", "barrier", "xpcom-barrier", "xpcom-barrier-unwrapped"]) {
+    do_print("Kind: " + kind);
+    let lock = makeLock(kind);
+
+    let deferredOuter = PromiseUtils.defer();
+    let deferredInner = PromiseUtils.defer();
+    let deferredBlockInner = PromiseUtils.defer();
 
-    if (kind == "xpcom-barrier") {
-      do_print("Skipping this part of the test that is caught differently by XPConnect");
-      continue;
-    }
-    do_print("Ensure that an incomplete blocker causes a TypeError");
+    lock.addBlocker("Outer blocker", () => {
+      do_print("Entering outer blocker");
+      deferredOuter.resolve();
+      lock.addBlocker("Inner blocker", () => {
+        do_print("Entering inner blocker");
+        deferredInner.resolve();
+        return deferredBlockInner.promise;
+      });
+    });
 
-    lock = makeLock(kind);
-    exn = get_exn(() => lock.addBlocker());
-    do_check_exn(exn, "TypeError");
+    // Note that phase-style locks spin the event loop and do not return from
+    // `lock.wait()` until after all blockers have been resolved. Therefore,
+    // to be able to test them, we need to dispatch the following steps to the
+    // event loop before calling `lock.wait()`, which we do by forcing
+    // a Promise.resolve().
+    //
+    let promiseSteps = Task.spawn(function* () {
+      yield Promise.resolve();
+
+      do_print("Waiting until we have entered the outer blocker");
+      yield deferredOuter.promise;
 
-    exn = get_exn(() => lock.addBlocker(null, true));
-    do_check_exn(exn, "TypeError");
+      do_print("Waiting until we have entered the inner blocker");
+      yield deferredInner.promise;
 
-    exn = get_exn(() => lock.addBlocker("Test 2", () => true, "not a function"));
-    do_check_exn(exn, "TypeError");
+      do_print("Allowing the lock to resolve")
+      deferredBlockInner.resolve();
+    });
+
+    do_print("Starting wait");
+    yield lock.wait();
+
+    do_print("Waiting until all steps have been walked");
+    yield promiseSteps;
   }
 });
 
 
 add_task(function* test_phase_removeBlocker() {
   do_print("Testing that we can call removeBlocker before, during and after the call to wait()");
 
   for (let kind of ["phase", "barrier", "xpcom-barrier", "xpcom-barrier-unwrapped"]) {
@@ -144,9 +187,8 @@ add_task(function* test_state() {
 
   deferred.resolve();
   yield promiseDone;
 });
 
 add_task(function*() {
   Services.prefs.clearUserPref("toolkit.asyncshutdown.testing");
 });
-
--- a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
@@ -657,17 +657,22 @@ this.DownloadIntegration = {
         let unixMode = isTemporaryDownload ? 0o400 : 0o666;
         // On Unix, the umask of the process is respected.  This call has no
         // effect on Windows.
         yield OS.File.setPermissions(aDownload.target.path, { unixMode });
       } catch (ex) {
         // We should report errors with making the permissions less restrictive
         // or marking the file as read-only on Unix and Mac, but this should not
         // prevent the download from completing.
-        Cu.reportError(ex);
+        // The setPermissions API error EPERM is expected to occur when working
+        // on a file system that does not support file permissions, like FAT32,
+        // thus we don't report this error.
+        if (!(ex instanceof OS.File.Error) || ex.unixErrno != OS.Constants.libc.EPERM) {
+          Cu.reportError(ex);
+        }
       }
 
       gDownloadPlatform.downloadDone(NetUtil.newURI(aDownload.source.url),
                                      new FileUtils.File(aDownload.target.path),
                                      aDownload.contentType,
                                      aDownload.source.isPrivate);
       this.downloadDoneCalled = true;
     }.bind(this));
--- a/toolkit/components/passwordmgr/nsLoginManager.js
+++ b/toolkit/components/passwordmgr/nsLoginManager.js
@@ -71,17 +71,17 @@ LoginManager.prototype = {
             // Allows unwrapping the JavaScript object for regression tests.
             return this;
         }
 
         throw Cr.NS_ERROR_NO_INTERFACE;
     },
 
 
-    /* ---------- private memebers ---------- */
+    /* ---------- private members ---------- */
 
 
     __formFillService : null, // FormFillController, for username autocompleting
     get _formFillService() {
         if (!this.__formFillService)
             this.__formFillService =
                             Cc["@mozilla.org/satchel/form-fill-controller;1"].
                             getService(Ci.nsIFormFillController);
@@ -125,16 +125,18 @@ LoginManager.prototype = {
         if (Services.appinfo.processType ===
             Services.appinfo.PROCESS_TYPE_DEFAULT) {
             Services.obs.addObserver(this._observer, "passwordmgr-storage-replace",
                                      false);
 
             // Initialize storage so that asynchronous data loading can start.
             this._initStorage();
         }
+
+        Services.obs.addObserver(this._observer, "gather-telemetry", false);
     },
 
 
     _initStorage : function () {
 #ifdef ANDROID
         var contractID = "@mozilla.org/login-manager/storage/mozStorage;1";
 #else
         var contractID = "@mozilla.org/login-manager/storage/json;1";
@@ -193,22 +195,30 @@ LoginManager.prototype = {
             } else if (topic == "passwordmgr-storage-replace") {
                 Task.spawn(function () {
                   yield this._pwmgr._storage.terminate();
                   this._pwmgr._initStorage();
                   yield this._pwmgr.initializationPromise;
                   Services.obs.notifyObservers(null,
                                "passwordmgr-storage-replace-complete", null);
                 }.bind(this));
+            } else if (topic == "gather-telemetry") {
+                this._pwmgr._gatherTelemetry();
             } else {
                 log("Oops! Unexpected notification:", topic);
             }
         }
     },
 
+    _gatherTelemetry : function() {
+      let numPasswordsHist = Services.telemetry.getHistogramById("PWMGR_NUM_SAVED_PASSWORDS");
+      numPasswordsHist.clear();
+      numPasswordsHist.add(this.countLogins("", "", ""));
+    },
+
 
 
 
 
     /* ---------- Primary Public interfaces ---------- */
 
 
 
--- a/toolkit/components/search/nsSearchSuggestions.js
+++ b/toolkit/components/search/nsSearchSuggestions.js
@@ -24,19 +24,20 @@ function SuggestAutoComplete() {
 SuggestAutoComplete.prototype = {
 
   _init: function() {
     this._suggestionController = new SearchSuggestionController(obj => this.onResultsReturned(obj));
     this._suggestionController.maxLocalResults = this._historyLimit;
   },
 
   get _suggestionLabel() {
-    delete this._suggestionLabel;
     let bundle = Services.strings.createBundle("chrome://global/locale/search/search.properties");
-    return this._suggestionLabel = bundle.GetStringFromName("suggestion_label");
+    let label = bundle.GetStringFromName("suggestion_label");
+    Object.defineProperty(SuggestAutoComplete.prototype, "_suggestionLabel", {value: label});
+    return label;
   },
 
   /**
    * The object implementing nsIAutoCompleteObserver that we notify when
    * we have found results
    * @private
    */
   _listener: null,
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -7144,10 +7144,18 @@
     "description": "Stumbler: The time between receiving passive locations."
   },
   "DATA_STORAGE_ENTRIES": {
     "expires_in_version": "default",
     "kind": "linear",
     "high": "1024",
     "n_buckets": 16,
     "description": "The number of entries in persistent DataStorage (HSTS and HPKP data, basically)"
+  },
+  "PWMGR_NUM_SAVED_PASSWORDS": {
+    "expires_in_version": "never",
+    "kind": "exponential",
+    "high": 750,
+    "n_buckets" : 50,
+    "extended_statistics_ok": true,
+    "description": "The number of saved signons in storage"
   }
 }
--- a/toolkit/devtools/server/actors/script.js
+++ b/toolkit/devtools/server/actors/script.js
@@ -1696,16 +1696,28 @@ ThreadActor.prototype = {
 
       case "undefined":
         return { type: "undefined" };
 
       case "object":
         if (aValue === null) {
           return { type: "null" };
         }
+      else if(aValue.optimizedOut ||
+              aValue.uninitialized ||
+              aValue.missingArguments) {
+          // The slot is optimized out, an uninitialized binding, or
+          // arguments on a dead scope
+          return {
+            type: "null",
+            optimizedOut: aValue.optimizedOut,
+            uninitialized: aValue.uninitialized,
+            missingArguments: aValue.missingArguments
+          };
+        }
         return this.objectGrip(aValue, aPool);
 
       case "symbol":
         let form = {
           type: "symbol"
         };
         let name = getSymbolName(aValue);
         if (name !== undefined) {
@@ -4859,30 +4871,24 @@ EnvironmentActor.prototype = {
     }
 
     let parameterNames;
     if (this.obj.callee) {
       parameterNames = this.obj.callee.parameterNames;
     }
     for each (let name in parameterNames) {
       let arg = {};
-
       let value = this.obj.getVariable(name);
-      // The slot is optimized out.
-      // FIXME: Need actual UI, bug 941287.
-      if (value && value.optimizedOut) {
-        continue;
-      }
 
       // TODO: this part should be removed in favor of the commented-out part
       // below when getVariableDescriptor lands (bug 725815).
       let desc = {
         value: value,
         configurable: false,
-        writable: true,
+        writable: !(value && value.optimizedOut),
         enumerable: true
       };
 
       // let desc = this.obj.getVariableDescriptor(name);
       let descForm = {
         enumerable: true,
         configurable: desc.configurable
       };
@@ -4900,29 +4906,26 @@ EnvironmentActor.prototype = {
     for each (let name in this.obj.names()) {
       if (bindings.arguments.some(function exists(element) {
                                     return !!element[name];
                                   })) {
         continue;
       }
 
       let value = this.obj.getVariable(name);
-      // The slot is optimized out, arguments on a dead scope, or an
-      // uninitialized binding.
-      // FIXME: Need actual UI, bug 941287.
-      if (value && (value.optimizedOut || value.missingArguments || value.uninitialized)) {
-        continue;
-      }
 
       // TODO: this part should be removed in favor of the commented-out part
       // below when getVariableDescriptor lands.
       let desc = {
         value: value,
         configurable: false,
-        writable: true,
+        writable: !(value &&
+                    (value.optimizedOut ||
+                     value.uninitialized ||
+                     value.missingArguments)),
         enumerable: true
       };
 
       //let desc = this.obj.getVariableDescriptor(name);
       let descForm = {
         enumerable: true,
         configurable: desc.configurable
       };
--- a/toolkit/mozapps/downloads/DownloadLastDir.jsm
+++ b/toolkit/mozapps/downloads/DownloadLastDir.jsm
@@ -25,16 +25,17 @@
  */
 
 const LAST_DIR_PREF = "browser.download.lastDir";
 const SAVE_PER_SITE_PREF = LAST_DIR_PREF + ".savePerSite";
 const nsIFile = Components.interfaces.nsIFile;
 
 this.EXPORTED_SYMBOLS = [ "DownloadLastDir" ];
 
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 Components.utils.import("resource://gre/modules/Services.jsm");
 Components.utils.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
 
 let observer = {
   QueryInterface: function (aIID) {
     if (aIID.equals(Components.interfaces.nsIObserver) ||
         aIID.equals(Components.interfaces.nsISupports) ||
         aIID.equals(Components.interfaces.nsISupportsWeakReference))
@@ -83,43 +84,48 @@ function isContentPrefEnabled() {
   catch (e) {
     return true;
   }
 }
 
 let gDownloadLastDirFile = readLastDirPref();
 
 this.DownloadLastDir = function DownloadLastDir(aWindow) {
-  this.window = aWindow;
+  let loadContext = aWindow.QueryInterface(Components.interfaces.nsIInterfaceRequestor)
+                           .getInterface(Components.interfaces.nsIWebNavigation)
+                           .QueryInterface(Components.interfaces.nsILoadContext);
+  // Need this in case the real thing has gone away by the time we need it.
+  // We only care about the private browsing state. All the rest of the
+  // load context isn't of interest to the content pref service.
+  this.fakeContext = {
+    QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsILoadContext]),
+    usePrivateBrowsing: loadContext.usePrivateBrowsing
+  };
 }
 
 DownloadLastDir.prototype = {
   isPrivate: function DownloadLastDir_isPrivate() {
-    return PrivateBrowsingUtils.isWindowPrivate(this.window);
+    return this.fakeContext.usePrivateBrowsing;
   },
   // compat shims
   get file() this._getLastFile(),
   set file(val) { this.setFile(null, val); },
   cleanupPrivateFile: function () {
     gDownloadLastDirFile = null;
   },
   // This function is now deprecated as it uses the sync nsIContentPrefService
   // interface. New consumers should use the getFileAsync function.
   getFile: function (aURI) {
     let Deprecated = Components.utils.import("resource://gre/modules/Deprecated.jsm", {}).Deprecated;
     Deprecated.warning("DownloadLastDir.getFile is deprecated. Please use getFileAsync instead.",
                        "https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/DownloadLastDir.jsm",
                        Components.stack.caller);
 
     if (aURI && isContentPrefEnabled()) {
-      let loadContext = this.window
-                            .QueryInterface(Components.interfaces.nsIInterfaceRequestor)
-                            .getInterface(Components.interfaces.nsIWebNavigation)
-                            .QueryInterface(Components.interfaces.nsILoadContext);
-      let lastDir = Services.contentPrefs.getPref(aURI, LAST_DIR_PREF, loadContext);
+      let lastDir = Services.contentPrefs.getPref(aURI, LAST_DIR_PREF, this.fakeContext);
       if (lastDir) {
         var lastDirFile = Components.classes["@mozilla.org/file/local;1"]
                                     .createInstance(Components.interfaces.nsIFile);
         lastDirFile.initWithPath(lastDir);
         return lastDirFile;
       }
     }
     return this._getLastFile();
@@ -143,22 +149,18 @@ DownloadLastDir.prototype = {
       Services.tm.mainThread.dispatch(function() aCallback(plainPrefFile),
                                       Components.interfaces.nsIThread.DISPATCH_NORMAL);
       return;
     }
 
     let uri = aURI instanceof Components.interfaces.nsIURI ? aURI.spec : aURI;
     let cps2 = Components.classes["@mozilla.org/content-pref/service;1"]
                          .getService(Components.interfaces.nsIContentPrefService2);
-    let loadContext = this.window
-                          .QueryInterface(Components.interfaces.nsIInterfaceRequestor)
-                          .getInterface(Components.interfaces.nsIWebNavigation)
-                          .QueryInterface(Components.interfaces.nsILoadContext);
     let result = null;
-    cps2.getByDomainAndName(uri, LAST_DIR_PREF, loadContext, {
+    cps2.getByDomainAndName(uri, LAST_DIR_PREF, this.fakeContext, {
       handleResult: function(aResult) result = aResult,
       handleCompletion: function(aReason) {
         let file = plainPrefFile;
         if (aReason == Components.interfaces.nsIContentPrefCallback2.COMPLETE_OK &&
            result instanceof Components.interfaces.nsIContentPref) {
           file = Components.classes["@mozilla.org/file/local;1"]
                            .createInstance(Components.interfaces.nsIFile);
           file.initWithPath(result.value);
@@ -168,24 +170,20 @@ DownloadLastDir.prototype = {
     });
   },
 
   setFile: function (aURI, aFile) {
     if (aURI && isContentPrefEnabled()) {
       let uri = aURI instanceof Components.interfaces.nsIURI ? aURI.spec : aURI;
       let cps2 = Components.classes["@mozilla.org/content-pref/service;1"]
                            .getService(Components.interfaces.nsIContentPrefService2);
-      let loadContext = this.window
-                            .QueryInterface(Components.interfaces.nsIInterfaceRequestor)
-                            .getInterface(Components.interfaces.nsIWebNavigation)
-                            .QueryInterface(Components.interfaces.nsILoadContext);
       if (aFile instanceof Components.interfaces.nsIFile)
-        cps2.set(uri, LAST_DIR_PREF, aFile.path, loadContext);
+        cps2.set(uri, LAST_DIR_PREF, aFile.path, this.fakeContext);
       else
-        cps2.removeByDomainAndName(uri, LAST_DIR_PREF, loadContext);
+        cps2.removeByDomainAndName(uri, LAST_DIR_PREF, this.fakeContext);
     }
     if (this.isPrivate()) {
       if (aFile instanceof Components.interfaces.nsIFile)
         gDownloadLastDirFile = aFile.clone();
       else
         gDownloadLastDirFile = null;
     } else {
       if (aFile instanceof Components.interfaces.nsIFile)
--- a/toolkit/mozapps/update/nsUpdateService.js
+++ b/toolkit/mozapps/update/nsUpdateService.js
@@ -3018,18 +3018,18 @@ UpdateService.prototype = {
       return;
 
     // If the new version of this add-on is blocklisted for the new application
     // then it isn't a valid update and the user should still be warned that
     // the add-on will become incompatible.
     let bs = Cc["@mozilla.org/extensions/blocklist;1"].
              getService(Ci.nsIBlocklistService);
     if (bs.isAddonBlocklisted(addon,
-                              gUpdates.update.appVersion,
-                              gUpdates.update.platformVersion))
+                              this._update.appVersion,
+                              this._update.platformVersion))
       return;
 
     // Compatibility or new version updates mean the same thing here.
     this.onCompatibilityUpdateAvailable(addon);
   },
 
   onUpdateFinished: function(addon) {
     if (--this._updateCheckCount > 0)