Bug 1184921: allow custom buttons to be added to the chatbox titlebar and implement one for Hello that closes the window when clicked. r=Standard8,a=lhenry
authorMike de Boer <mdeboer@mozilla.com>
Wed, 30 Sep 2015 15:35:22 +0200
changeset 296291 fed07352f43ff012c990e18f8ca4070169a63431
parent 296290 91a9dd45c0716010dd61ff71f8901355014a1bf8
child 296292 d0aa348ede4b517880cb80f37a28386613298133
push id5245
push userraliiev@mozilla.com
push dateThu, 29 Oct 2015 11:30:51 +0000
treeherdermozilla-beta@dac831dc1bd0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8, lhenry
bugs1184921
milestone43.0a2
Bug 1184921: allow custom buttons to be added to the chatbox titlebar and implement one for Hello that closes the window when clicked. r=Standard8,a=lhenry
browser/base/content/socialchat.xml
browser/base/content/test/chat/browser_chatwindow.js
browser/base/content/test/chat/head.js
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/conversationAppStore.js
browser/components/loop/content/js/conversationViews.js
browser/components/loop/content/js/conversationViews.jsx
browser/components/loop/content/js/roomViews.js
browser/components/loop/content/js/roomViews.jsx
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/modules/MozLoopService.jsm
browser/components/loop/test/desktop-local/conversationAppStore_test.js
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/desktop-local/roomViews_test.js
browser/components/loop/test/shared/views_test.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
browser/modules/Chat.jsm
browser/themes/linux/browser.css
browser/themes/osx/browser.css
browser/themes/shared/social/chat-icons.svg
browser/themes/shared/social/chat.inc.css
browser/themes/windows/browser.css
--- a/browser/base/content/socialchat.xml
+++ b/browser/base/content/socialchat.xml
@@ -48,34 +48,31 @@
           let anonid = (idPrefix || getterPrefix) + "icon";
           this.content.__defineGetter__(getter, () => {
             delete this.content[getter];
             return this.content[getter] = document.getAnonymousElementByAttribute(
               this, "anonid", anonid);
           });
         }
 
-        if (!this.chatbar) {
-          document.getAnonymousElementByAttribute(this, "anonid", "minimize").hidden = true;
-          document.getAnonymousElementByAttribute(this, "anonid", "close").hidden = true;
-        }
         let contentWindow = this.contentWindow;
         // process this._callbacks, then set to null so the chatbox creator
         // knows to make new callbacks immediately.
         if (this._callbacks) {
           for (let callback of this._callbacks) {
             callback(this);
           }
           this._callbacks = null;
         }
         this.addEventListener("DOMContentLoaded", function DOMContentLoaded(event) {
           if (event.target != this.contentDocument)
             return;
           this.removeEventListener("DOMContentLoaded", DOMContentLoaded, true);
           this.isActive = !this.minimized;
+          this._chat.loadButtonSet(this, this.getAttribute("buttonSet"));
           this._deferredChatLoaded.resolve(this);
         }, true);
 
         if (this.src)
           this.setAttribute("src", this.src);
       ]]></constructor>
 
       <field name="_deferredChatLoaded" readonly="true">
@@ -87,16 +84,20 @@
           return this._deferredChatLoaded.promise;
         </getter>
       </property>
 
       <field name="content" readonly="true">
         document.getAnonymousElementByAttribute(this, "anonid", "content");
       </field>
 
+      <field name="_chat" readonly="true">
+        Cu.import("resource:///modules/Chat.jsm", {}).Chat;
+      </field>
+
       <property name="contentWindow">
         <getter>
           return this.content.contentWindow;
         </getter>
       </property>
 
       <property name="contentDocument">
         <getter>
@@ -166,20 +167,19 @@
           aTarget.content.popupnotificationanchor.className = this.content.popupnotificationanchor.className;
           aTarget.content.swapDocShells(this.content);
         ]]></body>
       </method>
 
       <method name="setDecorationAttributes">
         <parameter name="aTarget"/>
         <body><![CDATA[
-          for (let attr of ["dark", "customSize"]) {
-            if (this.hasAttribute(attr))
-              aTarget.setAttribute(attr, this.getAttribute(attr));
-          }
+          if (this.hasAttribute("customSize"))
+            aTarget.setAttribute("customSize", this.getAttribute("customSize"));
+          this._chat.loadButtonSet(aTarget, this.getAttribute("buttonSet"));
         ]]></body>
       </method>
 
       <method name="onTitlebarClick">
         <parameter name="aEvent"/>
         <body><![CDATA[
           if (!this.chatbar)
             return;
--- a/browser/base/content/test/chat/browser_chatwindow.js
+++ b/browser/base/content/test/chat/browser_chatwindow.js
@@ -126,8 +126,73 @@ add_chat_task(function* testChatWindowCh
   // either window or secondWindow (linux may choose a different one) but the
   // point is that the window is *not* the private one.
   Assert.ok(Chat.findChromeWindowForChats(null) == window ||
             Chat.findChromeWindowForChats(null) == secondWindow,
             "Private window isn't selected for new chats.");
   privateWindow.close();
   secondWindow.close();
 });
+
+add_chat_task(function* testButtonSet() {
+  let chatbox = yield promiseOpenChat("http://example.com#1");
+  let document = chatbox.ownerDocument;
+
+  // Expect all default buttons to be visible.
+  for (let buttonId of kDefaultButtonSet) {
+    let button = document.getAnonymousElementByAttribute(chatbox, "anonid", buttonId);
+    Assert.ok(!button.hidden, "Button '" + buttonId + "' should be visible");
+  }
+
+  let visible = new Set(["minimize", "close"]);
+  chatbox = yield promiseOpenChat("http://example.com#2", null, null, [...visible].join(","));
+
+  for (let buttonId of kDefaultButtonSet) {
+    let button = document.getAnonymousElementByAttribute(chatbox, "anonid", buttonId);
+    if (visible.has(buttonId)) {
+      Assert.ok(!button.hidden, "Button '" + buttonId + "' should be visible");
+    } else {
+      Assert.ok(button.hidden, "Button '" + buttonId + "' should NOT be visible");
+    }
+  }
+});
+
+add_chat_task(function* testCustomButton() {
+  let commanded = 0;
+  let customButton = {
+    id: "custom",
+    onCommand: function() {
+      ++commanded;
+    }
+  };
+
+  Chat.registerButton(customButton);
+
+  let chatbox = yield promiseOpenChat("http://example.com#1");
+  let document = chatbox.ownerDocument;
+  let titlebarNode = document.getAnonymousElementByAttribute(chatbox, "class",
+    "chat-titlebar");
+
+  Assert.equal(titlebarNode.getElementsByClassName("chat-custom")[0], null,
+    "Custom chat button should not be in the toolbar yet.");
+
+  let visible = new Set(["minimize", "close", "custom"]);
+  Chat.loadButtonSet(chatbox, [...visible].join(","));
+
+  for (let buttonId of kDefaultButtonSet) {
+    let button = document.getAnonymousElementByAttribute(chatbox, "anonid", buttonId);
+    if (visible.has(buttonId)) {
+      Assert.ok(!button.hidden, "Button '" + buttonId + "' should be visible");
+    } else {
+      Assert.ok(button.hidden, "Button '" + buttonId + "' should NOT be visible");
+    }
+  }
+
+  let customButtonNode = titlebarNode.getElementsByClassName("chat-custom")[0];
+  Assert.ok(!customButtonNode.hidden, "Custom button should be visible");
+
+  let ev = document.createEvent("XULCommandEvent");
+  ev.initCommandEvent("command", true, true, document.defaultView, 0, false,
+    false, false, false, null);
+  customButtonNode.dispatchEvent(ev);
+
+  Assert.equal(commanded, 1, "Button should have been commanded once");
+});
--- a/browser/base/content/test/chat/head.js
+++ b/browser/base/content/test/chat/head.js
@@ -1,17 +1,18 @@
 /* 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/. */
 
 // Utility functions for Chat tests.
 
 var Chat = Cu.import("resource:///modules/Chat.jsm", {}).Chat;
+const kDefaultButtonSet = new Set(["minimize", "swap", "close"]);
 
-function promiseOpenChat(url, mode, focus) {
+function promiseOpenChat(url, mode, focus, buttonSet = null) {
   let uri = Services.io.newURI(url, null, null);
   let origin = uri.prePath;
   let title = origin;
   let deferred = Promise.defer();
   // we just through a few hoops to ensure the content document is fully
   // loaded, otherwise tests that rely on that content may intermittently fail.
   let callback = function(chatbox) {
     if (chatbox.contentDocument.readyState == "complete") {
@@ -23,16 +24,19 @@ function promiseOpenChat(url, mode, focu
       if (event.target != chatbox.contentDocument || chatbox.contentDocument.location.href == "about:blank") {
         return;
       }
       chatbox.removeEventListener("load", onload, true);
       deferred.resolve(chatbox);
     }, true);
   }
   let chatbox = Chat.open(null, origin, title, url, mode, focus, callback);
+  if (buttonSet) {
+    chatbox.setAttribute("buttonSet", buttonSet);
+  }
   return deferred.promise;
 }
 
 // Opens a chat, returns a promise resolved when the chat callback fired.
 function promiseOpenChatCallback(url, mode) {
   let uri = Services.io.newURI(url, null, null);
   let origin = uri.prePath;
   let title = origin;
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -67,22 +67,24 @@ loop.conversation = (function(mozL10n) {
         return this._renderFeedbackForm();
       }
 
       switch(this.state.windowType) {
         // CallControllerView is used for both.
         case "incoming":
         case "outgoing": {
           return (React.createElement(CallControllerView, {
+            chatWindowDetached: this.state.chatWindowDetached, 
             dispatcher: this.props.dispatcher, 
             mozLoop: this.props.mozLoop, 
             onCallTerminated: this.handleCallTerminated}));
         }
         case "room": {
           return (React.createElement(DesktopRoomConversationView, {
+            chatWindowDetached: this.state.chatWindowDetached, 
             dispatcher: this.props.dispatcher, 
             mozLoop: this.props.mozLoop, 
             onCallTerminated: this.handleCallTerminated, 
             roomStore: this.props.roomStore}));
         }
         case "failed": {
           return (React.createElement(DirectCallFailureView, {
             contact: {}, 
@@ -133,31 +135,32 @@ loop.conversation = (function(mozL10n) {
       sdk: OT,
       mozLoop: navigator.mozLoop
     });
 
     // expose for functional tests
     loop.conversation._sdkDriver = sdkDriver;
 
     // Create the stores.
-    var conversationAppStore = new loop.store.ConversationAppStore({
-      dispatcher: dispatcher,
-      mozLoop: navigator.mozLoop
-    });
     var conversationStore = new loop.store.ConversationStore(dispatcher, {
       client: client,
       isDesktop: true,
       mozLoop: navigator.mozLoop,
       sdkDriver: sdkDriver
     });
     var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
       isDesktop: true,
       mozLoop: navigator.mozLoop,
       sdkDriver: sdkDriver
     });
+    var conversationAppStore = new loop.store.ConversationAppStore({
+      activeRoomStore: activeRoomStore,
+      dispatcher: dispatcher,
+      mozLoop: navigator.mozLoop
+    });
     var roomStore = new loop.store.RoomStore(dispatcher, {
       mozLoop: navigator.mozLoop,
       activeRoomStore: activeRoomStore
     });
     var textChatStore = new loop.store.TextChatStore(dispatcher, {
       sdkDriver: sdkDriver
     });
 
@@ -171,20 +174,16 @@ loop.conversation = (function(mozL10n) {
     var locationHash = loop.shared.utils.locationData().hash;
     var windowId;
 
     var hash = locationHash.match(/#(.*)/);
     if (hash) {
       windowId = hash[1];
     }
 
-    window.addEventListener("unload", function(event) {
-      dispatcher.dispatch(new sharedActions.WindowUnload());
-    });
-
     React.render(
       React.createElement(AppControllerView, {
         dispatcher: dispatcher, 
         mozLoop: navigator.mozLoop, 
         roomStore: roomStore}), document.querySelector("#main"));
 
     document.documentElement.setAttribute("lang", mozL10n.getLanguage());
     document.documentElement.setAttribute("dir", mozL10n.getDirection());
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -67,22 +67,24 @@ loop.conversation = (function(mozL10n) {
         return this._renderFeedbackForm();
       }
 
       switch(this.state.windowType) {
         // CallControllerView is used for both.
         case "incoming":
         case "outgoing": {
           return (<CallControllerView
+            chatWindowDetached={this.state.chatWindowDetached}
             dispatcher={this.props.dispatcher}
             mozLoop={this.props.mozLoop}
             onCallTerminated={this.handleCallTerminated} />);
         }
         case "room": {
           return (<DesktopRoomConversationView
+            chatWindowDetached={this.state.chatWindowDetached}
             dispatcher={this.props.dispatcher}
             mozLoop={this.props.mozLoop}
             onCallTerminated={this.handleCallTerminated}
             roomStore={this.props.roomStore} />);
         }
         case "failed": {
           return (<DirectCallFailureView
             contact={{}}
@@ -133,31 +135,32 @@ loop.conversation = (function(mozL10n) {
       sdk: OT,
       mozLoop: navigator.mozLoop
     });
 
     // expose for functional tests
     loop.conversation._sdkDriver = sdkDriver;
 
     // Create the stores.
-    var conversationAppStore = new loop.store.ConversationAppStore({
-      dispatcher: dispatcher,
-      mozLoop: navigator.mozLoop
-    });
     var conversationStore = new loop.store.ConversationStore(dispatcher, {
       client: client,
       isDesktop: true,
       mozLoop: navigator.mozLoop,
       sdkDriver: sdkDriver
     });
     var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
       isDesktop: true,
       mozLoop: navigator.mozLoop,
       sdkDriver: sdkDriver
     });
+    var conversationAppStore = new loop.store.ConversationAppStore({
+      activeRoomStore: activeRoomStore,
+      dispatcher: dispatcher,
+      mozLoop: navigator.mozLoop
+    });
     var roomStore = new loop.store.RoomStore(dispatcher, {
       mozLoop: navigator.mozLoop,
       activeRoomStore: activeRoomStore
     });
     var textChatStore = new loop.store.TextChatStore(dispatcher, {
       sdkDriver: sdkDriver
     });
 
@@ -171,20 +174,16 @@ loop.conversation = (function(mozL10n) {
     var locationHash = loop.shared.utils.locationData().hash;
     var windowId;
 
     var hash = locationHash.match(/#(.*)/);
     if (hash) {
       windowId = hash[1];
     }
 
-    window.addEventListener("unload", function(event) {
-      dispatcher.dispatch(new sharedActions.WindowUnload());
-    });
-
     React.render(
       <AppControllerView
         dispatcher={dispatcher}
         mozLoop={navigator.mozLoop}
         roomStore={roomStore} />, document.querySelector("#main"));
 
     document.documentElement.setAttribute("lang", mozL10n.getLanguage());
     document.documentElement.setAttribute("dir", mozL10n.getDirection());
--- a/browser/components/loop/content/js/conversationAppStore.js
+++ b/browser/components/loop/content/js/conversationAppStore.js
@@ -10,39 +10,55 @@ loop.store = loop.store || {};
  * the window data and store the window type.
  */
 loop.store.ConversationAppStore = (function() {
   "use strict";
 
   /**
    * Constructor
    *
-   * @param {Object} options Options for the store. Should contain the dispatcher.
+   * @param {Object} options Options for the store. Should contain the
+   *                         activeRoomStore, dispatcher and mozLoop objects.
    */
   var ConversationAppStore = function(options) {
+    if (!options.activeRoomStore) {
+      throw new Error("Missing option activeRoomStore");
+    }
     if (!options.dispatcher) {
       throw new Error("Missing option dispatcher");
     }
     if (!options.mozLoop) {
       throw new Error("Missing option mozLoop");
     }
 
+    this._activeRoomStore = options.activeRoomStore;
     this._dispatcher = options.dispatcher;
     this._mozLoop = options.mozLoop;
+    this._rootObj = ("rootObject" in options) ? options.rootObject : window;
     this._storeState = this.getInitialStoreState();
 
+    // Start listening for specific events, coming from the window object.
+    this._eventHandlers = {};
+    ["unload", "LoopHangupNow", "socialFrameAttached", "socialFrameDetached"]
+      .forEach(function(eventName) {
+        var handlerName = eventName + "Handler";
+        this._eventHandlers[eventName] = this[handlerName].bind(this);
+        this._rootObj.addEventListener(eventName, this._eventHandlers[eventName]);
+      }.bind(this));
+
     this._dispatcher.register(this, [
       "getWindowData",
       "showFeedbackForm"
     ]);
   };
 
   ConversationAppStore.prototype = _.extend({
     getInitialStoreState: function() {
       return {
+        chatWindowDetached: false,
         // How often to display the form. Convert seconds to ms.
         feedbackPeriod: this._mozLoop.getLoopPref("feedback.periodSec") * 1000,
         // Date when the feedback form was last presented. Convert to ms.
         feedbackTimestamp: this._mozLoop
                                .getLoopPref("feedback.dateLastSeenSec") * 1000,
         showFeedbackForm: false
       };
     },
@@ -57,17 +73,17 @@ loop.store.ConversationAppStore = (funct
     },
 
     /**
      * Updates store states and trigger a "change" event.
      *
      * @param {Object} state The new store state.
      */
     setStoreState: function(state) {
-      this._storeState = state;
+      this._storeState = _.extend({}, this._storeState, state);
       this.trigger("change");
     },
 
     /**
      * Sets store state which will result in the feedback form rendered.
      * Saves a timestamp of when the feedback was last rendered.
      */
     showFeedbackForm: function() {
@@ -94,14 +110,72 @@ loop.store.ConversationAppStore = (funct
         this.setStoreState({windowType: "failed"});
         return;
       }
 
       this.setStoreState({windowType: windowData.type});
 
       this._dispatcher.dispatch(new loop.shared.actions.SetupWindowData(_.extend({
         windowId: actionData.windowId}, windowData)));
+    },
+
+    /**
+     * Event handler; invoked when the 'unload' event is dispatched from the
+     * window object.
+     * It will dispatch a 'WindowUnload' action that other stores may listen to
+     * and will remove all event handlers attached to the window object.
+     */
+    unloadHandler: function() {
+      this._dispatcher.dispatch(new loop.shared.actions.WindowUnload());
+
+      // Unregister event handlers.
+      var eventNames = Object.getOwnPropertyNames(this._eventHandlers);
+      eventNames.forEach(function(eventName) {
+        this._rootObj.removeEventListener(eventName, this._eventHandlers[eventName]);
+      }.bind(this));
+      this._eventHandlers = null;
+    },
+
+    /**
+     * Event handler; invoked when the 'LoopHangupNow' event is dispatched from
+     * the window object.
+     * It'll attempt to gracefully disconnect from an active session, or close
+     * the window when no session is currently active.
+     */
+    LoopHangupNowHandler: function() {
+      switch (this.getStoreState().windowType) {
+        case "incoming":
+        case "outgoing":
+          this._dispatcher.dispatch(new loop.shared.actions.HangupCall());
+          break;
+        case "room":
+          if (this._activeRoomStore.getStoreState().used) {
+            this._dispatcher.dispatch(new loop.shared.actions.LeaveRoom());
+          } else {
+            loop.shared.mixins.WindowCloseMixin.closeWindow();
+          }
+          break;
+        default:
+          loop.shared.mixins.WindowCloseMixin.closeWindow();
+          break;
+      }
+    },
+
+    /**
+     * Event handler; invoked when the 'socialFrameAttached' event is dispatched
+     * from the window object.
+     */
+    socialFrameAttachedHandler: function() {
+      this.setStoreState({ chatWindowDetached: false });
+    },
+
+    /**
+     * Event handler; invoked when the 'socialFrameDetached' event is dispatched
+     * from the window object.
+     */
+    socialFrameDetachedHandler: function() {
+      this.setStoreState({ chatWindowDetached: true });
     }
   }, Backbone.Events);
 
   return ConversationAppStore;
 
 })();
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -571,16 +571,17 @@ loop.conversationViews = (function(mozL1
   var OngoingConversationView = React.createClass({displayName: "OngoingConversationView",
     mixins: [
       sharedMixins.MediaSetupMixin
     ],
 
     propTypes: {
       // local
       audio: React.PropTypes.object,
+      chatWindowDetached: React.PropTypes.bool.isRequired,
       // We pass conversationStore here rather than use the mixin, to allow
       // easy configurability for the ui-showcase.
       conversationStore: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // The poster URLs are for UI-showcase testing and development.
       localPosterUrl: React.PropTypes.string,
       // This is used from the props rather than the state to make it easier for
       // the ui-showcase.
@@ -707,24 +708,25 @@ loop.conversationViews = (function(mozL1
             matchMedia: this.state.matchMedia || window.matchMedia.bind(window), 
             remotePosterUrl: this.props.remotePosterUrl, 
             remoteSrcMediaElement: this.state.remoteSrcMediaElement, 
             renderRemoteVideo: this.shouldRenderRemoteVideo(), 
             screenShareMediaElement: this.state.screenShareMediaElement, 
             screenSharePosterUrl: null, 
             showContextRoomName: false, 
             useDesktopPaths: true}, 
-            React.createElement(loop.shared.views.ConversationToolbar, {
+            React.createElement(sharedViews.ConversationToolbar, {
               audio: this.props.audio, 
               dispatcher: this.props.dispatcher, 
               hangup: this.hangup, 
               mozLoop: this.props.mozLoop, 
               publishStream: this.publishStream, 
               settingsMenuItems: settingsMenuItems, 
               show: true, 
+              showHangup: this.props.chatWindowDetached, 
               video: this.props.video})
           )
         )
       );
     }
   });
 
   /**
@@ -735,16 +737,17 @@ loop.conversationViews = (function(mozL1
     mixins: [
       sharedMixins.AudioMixin,
       sharedMixins.DocumentTitleMixin,
       loop.store.StoreMixin("conversationStore"),
       Backbone.Events
     ],
 
     propTypes: {
+      chatWindowDetached: React.PropTypes.bool.isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       mozLoop: React.PropTypes.object.isRequired,
       onCallTerminated: React.PropTypes.func.isRequired
     },
 
     getInitialState: function() {
       return this.getStoreState();
     },
@@ -814,16 +817,17 @@ loop.conversationViews = (function(mozL1
           return (React.createElement(DirectCallFailureView, {
             dispatcher: this.props.dispatcher, 
             mozLoop: this.props.mozLoop, 
             outgoing: this.state.outgoing}));
         }
         case CALL_STATES.ONGOING: {
           return (React.createElement(OngoingConversationView, {
             audio: { enabled: !this.state.audioMuted, visible: true}, 
+            chatWindowDetached: this.props.chatWindowDetached, 
             conversationStore: this.getStore(), 
             dispatcher: this.props.dispatcher, 
             mediaConnected: this.state.mediaConnected, 
             mozLoop: this.props.mozLoop, 
             remoteSrcMediaElement: this.state.remoteSrcMediaElement, 
             remoteVideoEnabled: this.state.remoteVideoEnabled, 
             video: { enabled: !this.state.videoMuted, visible: true}})
           );
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -571,16 +571,17 @@ loop.conversationViews = (function(mozL1
   var OngoingConversationView = React.createClass({
     mixins: [
       sharedMixins.MediaSetupMixin
     ],
 
     propTypes: {
       // local
       audio: React.PropTypes.object,
+      chatWindowDetached: React.PropTypes.bool.isRequired,
       // We pass conversationStore here rather than use the mixin, to allow
       // easy configurability for the ui-showcase.
       conversationStore: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // The poster URLs are for UI-showcase testing and development.
       localPosterUrl: React.PropTypes.string,
       // This is used from the props rather than the state to make it easier for
       // the ui-showcase.
@@ -707,24 +708,25 @@ loop.conversationViews = (function(mozL1
             matchMedia={this.state.matchMedia || window.matchMedia.bind(window)}
             remotePosterUrl={this.props.remotePosterUrl}
             remoteSrcMediaElement={this.state.remoteSrcMediaElement}
             renderRemoteVideo={this.shouldRenderRemoteVideo()}
             screenShareMediaElement={this.state.screenShareMediaElement}
             screenSharePosterUrl={null}
             showContextRoomName={false}
             useDesktopPaths={true}>
-            <loop.shared.views.ConversationToolbar
+            <sharedViews.ConversationToolbar
               audio={this.props.audio}
               dispatcher={this.props.dispatcher}
               hangup={this.hangup}
               mozLoop={this.props.mozLoop}
               publishStream={this.publishStream}
               settingsMenuItems={settingsMenuItems}
               show={true}
+              showHangup={this.props.chatWindowDetached}
               video={this.props.video} />
           </sharedViews.MediaLayoutView>
         </div>
       );
     }
   });
 
   /**
@@ -735,16 +737,17 @@ loop.conversationViews = (function(mozL1
     mixins: [
       sharedMixins.AudioMixin,
       sharedMixins.DocumentTitleMixin,
       loop.store.StoreMixin("conversationStore"),
       Backbone.Events
     ],
 
     propTypes: {
+      chatWindowDetached: React.PropTypes.bool.isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       mozLoop: React.PropTypes.object.isRequired,
       onCallTerminated: React.PropTypes.func.isRequired
     },
 
     getInitialState: function() {
       return this.getStoreState();
     },
@@ -814,16 +817,17 @@ loop.conversationViews = (function(mozL1
           return (<DirectCallFailureView
             dispatcher={this.props.dispatcher}
             mozLoop={this.props.mozLoop}
             outgoing={this.state.outgoing} />);
         }
         case CALL_STATES.ONGOING: {
           return (<OngoingConversationView
             audio={{ enabled: !this.state.audioMuted, visible: true }}
+            chatWindowDetached={this.props.chatWindowDetached}
             conversationStore={this.getStore()}
             dispatcher={this.props.dispatcher}
             mediaConnected={this.state.mediaConnected}
             mozLoop={this.props.mozLoop}
             remoteSrcMediaElement={this.state.remoteSrcMediaElement}
             remoteVideoEnabled={this.state.remoteVideoEnabled}
             video={{ enabled: !this.state.videoMuted, visible: true }} />
           );
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -545,16 +545,17 @@ loop.roomViews = (function(mozL10n) {
       ActiveRoomStoreMixin,
       sharedMixins.DocumentTitleMixin,
       sharedMixins.MediaSetupMixin,
       sharedMixins.RoomsAudioMixin,
       sharedMixins.WindowCloseMixin
     ],
 
     propTypes: {
+      chatWindowDetached: React.PropTypes.bool.isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // The poster URLs are for UI-showcase testing and development.
       localPosterUrl: React.PropTypes.string,
       mozLoop: React.PropTypes.object.isRequired,
       onCallTerminated: React.PropTypes.func.isRequired,
       remotePosterUrl: React.PropTypes.string,
       roomStore: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
     },
@@ -783,16 +784,17 @@ loop.roomViews = (function(mozL10n) {
                   audio: {enabled: !this.state.audioMuted, visible: true}, 
                   dispatcher: this.props.dispatcher, 
                   hangup: this.leaveRoom, 
                   mozLoop: this.props.mozLoop, 
                   publishStream: this.publishStream, 
                   screenShare: screenShareData, 
                   settingsMenuItems: settingsMenuItems, 
                   show: !shouldRenderEditContextView, 
+                  showHangup: this.props.chatWindowDetached, 
                   video: {enabled: !this.state.videoMuted, visible: true}}), 
                 React.createElement(DesktopRoomInvitationView, {
                   dispatcher: this.props.dispatcher, 
                   error: this.state.error, 
                   mozLoop: this.props.mozLoop, 
                   onAddContextClick: this.handleAddContextClick, 
                   onEditContextClose: this.handleEditContextClose, 
                   roomData: roomData, 
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -545,16 +545,17 @@ loop.roomViews = (function(mozL10n) {
       ActiveRoomStoreMixin,
       sharedMixins.DocumentTitleMixin,
       sharedMixins.MediaSetupMixin,
       sharedMixins.RoomsAudioMixin,
       sharedMixins.WindowCloseMixin
     ],
 
     propTypes: {
+      chatWindowDetached: React.PropTypes.bool.isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // The poster URLs are for UI-showcase testing and development.
       localPosterUrl: React.PropTypes.string,
       mozLoop: React.PropTypes.object.isRequired,
       onCallTerminated: React.PropTypes.func.isRequired,
       remotePosterUrl: React.PropTypes.string,
       roomStore: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
     },
@@ -783,16 +784,17 @@ loop.roomViews = (function(mozL10n) {
                   audio={{enabled: !this.state.audioMuted, visible: true}}
                   dispatcher={this.props.dispatcher}
                   hangup={this.leaveRoom}
                   mozLoop={this.props.mozLoop}
                   publishStream={this.publishStream}
                   screenShare={screenShareData}
                   settingsMenuItems={settingsMenuItems}
                   show={!shouldRenderEditContextView}
+                  showHangup={this.props.chatWindowDetached}
                   video={{enabled: !this.state.videoMuted, visible: true}} />
                 <DesktopRoomInvitationView
                   dispatcher={this.props.dispatcher}
                   error={this.state.error}
                   mozLoop={this.props.mozLoop}
                   onAddContextClick={this.handleAddContextClick}
                   onEditContextClose={this.handleEditContextClose}
                   roomData={roomData}
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -371,17 +371,18 @@ loop.shared.views = (function(_, mozL10n
    */
   var ConversationToolbar = React.createClass({displayName: "ConversationToolbar",
     getDefaultProps: function() {
       return {
         video: {enabled: true, visible: true},
         audio: {enabled: true, visible: true},
         screenShare: {state: SCREEN_SHARE_STATES.INACTIVE, visible: false},
         settingsMenuItems: null,
-        enableHangup: true
+        enableHangup: true,
+        showHangup: true
       };
     },
 
     getInitialState: function() {
       return {
         idle: false
       };
     },
@@ -392,16 +393,17 @@ loop.shared.views = (function(_, mozL10n
       enableHangup: React.PropTypes.bool,
       hangup: React.PropTypes.func.isRequired,
       hangupButtonLabel: React.PropTypes.string,
       mozLoop: React.PropTypes.object,
       publishStream: React.PropTypes.func.isRequired,
       screenShare: React.PropTypes.object,
       settingsMenuItems: React.PropTypes.array,
       show: React.PropTypes.bool.isRequired,
+      showHangup: React.PropTypes.bool,
       video: React.PropTypes.object.isRequired
     },
 
     handleClickHangup: function() {
       this.props.hangup();
     },
 
     handleToggleVideo: function() {
@@ -488,24 +490,27 @@ loop.shared.views = (function(_, mozL10n
         "idle": this.state.idle
       });
       var mediaButtonGroupCssClasses = cx({
         "conversation-toolbar-media-btn-group-box": true,
         "hide": (!this.props.video.visible && !this.props.audio.visible)
       });
       return (
         React.createElement("ul", {className: conversationToolbarCssClasses}, 
-          React.createElement("li", {className: "conversation-toolbar-btn-box btn-hangup-entry"}, 
-            React.createElement("button", {className: "btn btn-hangup", 
-                    disabled: !this.props.enableHangup, 
-                    onClick: this.handleClickHangup, 
-                    title: mozL10n.get("hangup_button_title")}, 
-              this._getHangupButtonLabel()
-            )
-          ), 
+          
+            this.props.showHangup ?
+            React.createElement("li", {className: "conversation-toolbar-btn-box btn-hangup-entry"}, 
+              React.createElement("button", {className: "btn btn-hangup", 
+                      disabled: !this.props.enableHangup, 
+                      onClick: this.handleClickHangup, 
+                      title: mozL10n.get("hangup_button_title")}, 
+                this._getHangupButtonLabel()
+              )
+            ) : null, 
+          
           React.createElement("li", {className: "conversation-toolbar-btn-box"}, 
             React.createElement("div", {className: mediaButtonGroupCssClasses}, 
                 React.createElement(MediaControlButton, {action: this.handleToggleVideo, 
                                     enabled: this.props.video.enabled, 
                                     scope: "local", type: "video", 
                                     visible: this.props.video.visible}), 
                 React.createElement(MediaControlButton, {action: this.handleToggleAudio, 
                                     enabled: this.props.audio.enabled, 
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -371,17 +371,18 @@ loop.shared.views = (function(_, mozL10n
    */
   var ConversationToolbar = React.createClass({
     getDefaultProps: function() {
       return {
         video: {enabled: true, visible: true},
         audio: {enabled: true, visible: true},
         screenShare: {state: SCREEN_SHARE_STATES.INACTIVE, visible: false},
         settingsMenuItems: null,
-        enableHangup: true
+        enableHangup: true,
+        showHangup: true
       };
     },
 
     getInitialState: function() {
       return {
         idle: false
       };
     },
@@ -392,16 +393,17 @@ loop.shared.views = (function(_, mozL10n
       enableHangup: React.PropTypes.bool,
       hangup: React.PropTypes.func.isRequired,
       hangupButtonLabel: React.PropTypes.string,
       mozLoop: React.PropTypes.object,
       publishStream: React.PropTypes.func.isRequired,
       screenShare: React.PropTypes.object,
       settingsMenuItems: React.PropTypes.array,
       show: React.PropTypes.bool.isRequired,
+      showHangup: React.PropTypes.bool,
       video: React.PropTypes.object.isRequired
     },
 
     handleClickHangup: function() {
       this.props.hangup();
     },
 
     handleToggleVideo: function() {
@@ -488,24 +490,27 @@ loop.shared.views = (function(_, mozL10n
         "idle": this.state.idle
       });
       var mediaButtonGroupCssClasses = cx({
         "conversation-toolbar-media-btn-group-box": true,
         "hide": (!this.props.video.visible && !this.props.audio.visible)
       });
       return (
         <ul className={conversationToolbarCssClasses}>
-          <li className="conversation-toolbar-btn-box btn-hangup-entry">
-            <button className="btn btn-hangup"
-                    disabled={!this.props.enableHangup}
-                    onClick={this.handleClickHangup}
-                    title={mozL10n.get("hangup_button_title")}>
-              {this._getHangupButtonLabel()}
-            </button>
-          </li>
+          {
+            this.props.showHangup ?
+            <li className="conversation-toolbar-btn-box btn-hangup-entry">
+              <button className="btn btn-hangup"
+                      disabled={!this.props.enableHangup}
+                      onClick={this.handleClickHangup}
+                      title={mozL10n.get("hangup_button_title")}>
+                {this._getHangupButtonLabel()}
+              </button>
+            </li> : null
+          }
           <li className="conversation-toolbar-btn-box">
             <div className={mediaButtonGroupCssClasses}>
                 <MediaControlButton action={this.handleToggleVideo}
                                     enabled={this.props.video.enabled}
                                     scope="local" type="video"
                                     visible={this.props.video.visible}/>
                 <MediaControlButton action={this.handleToggleAudio}
                                     enabled={this.props.audio.enabled}
--- a/browser/components/loop/modules/MozLoopService.jsm
+++ b/browser/components/loop/modules/MozLoopService.jsm
@@ -83,16 +83,26 @@ const ROOM_DELETE = {
 const ROOM_CONTEXT_ADD = {
   ADD_FROM_PANEL: 0,
   ADD_FROM_CONVERSATION: 1
 };
 
 // See LOG_LEVELS in Console.jsm. Common examples: "All", "Info", "Warn", & "Error".
 const PREF_LOG_LEVEL = "loop.debug.loglevel";
 
+const kChatboxHangupButton = {
+  id: "loop-hangup",
+  visibleWhenUndocked: false,
+  onCommand: function(e, chatbox) {
+    let window = chatbox.content.contentWindow;
+    let event = new window.CustomEvent("LoopHangupNow");
+    window.dispatchEvent(event);
+  }
+};
+
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
 Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
 
@@ -883,16 +893,18 @@ var MozLoopServiceInternal = {
     // So I guess the origin is the loop server!?
     let origin = this.loopServerUri;
     let windowId = this.getChatWindowID(conversationWindowData);
 
     gConversationWindowData.set(windowId, conversationWindowData);
 
     let url = this.getChatURL(windowId);
 
+    Chat.registerButton(kChatboxHangupButton);
+
     let callback = chatbox => {
       // We need to use DOMContentLoaded as otherwise the injection will happen
       // in about:blank and then get lost.
       // Sadly we can't use chatbox.promiseChatLoaded() as promise chaining
       // involves event loop spins, which means it might be too late.
       // Have we already done it?
       if (chatbox.contentWindow.navigator.mozLoop) {
         return;
@@ -992,21 +1004,21 @@ var MozLoopServiceInternal = {
     };
 
     let chatboxInstance = Chat.open(null, origin, "", url, undefined, undefined,
                                     callback);
     if (!chatboxInstance) {
       return null;
     // It's common for unit tests to overload Chat.open.
     } else if (chatboxInstance.setAttribute) {
-      // Set properties that influence visual appeara nce of the chatbox right
+      // Set properties that influence visual appearance of the chatbox right
       // away to circumvent glitches.
-      chatboxInstance.setAttribute("dark", true);
       chatboxInstance.setAttribute("customSize", "loopDefault");
       chatboxInstance.parentNode.setAttribute("customSize", "loopDefault");
+      Chat.loadButtonSet(chatboxInstance, "minimize,swap," + kChatboxHangupButton.id);
     }
     return windowId;
   },
 
   /**
    * Fetch Firefox Accounts (FxA) OAuth parameters from the Loop Server.
    *
    * @return {Promise} resolved with the body of the hawk request for OAuth parameters.
--- a/browser/components/loop/test/desktop-local/conversationAppStore_test.js
+++ b/browser/components/loop/test/desktop-local/conversationAppStore_test.js
@@ -1,40 +1,79 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 describe("loop.store.ConversationAppStore", function () {
   "use strict";
 
   var expect = chai.expect;
   var sharedActions = loop.shared.actions;
-  var sandbox, dispatcher;
+  var sandbox, activeRoomStore, dispatcher, roomUsed;
 
   beforeEach(function() {
+    roomUsed = false;
+    activeRoomStore = {
+      getStoreState: function() { return { used: roomUsed }; }
+    };
     sandbox = sinon.sandbox.create();
     dispatcher = new loop.Dispatcher();
     sandbox.stub(dispatcher, "dispatch");
   });
 
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("#constructor", function() {
+    it("should throw an error if the activeRoomStore is missing", function() {
+      expect(function() {
+        new loop.store.ConversationAppStore({
+          dispatcher: dispatcher,
+          mozLoop: {}
+        });
+      }).to.Throw(/activeRoomStore/);
+    });
+
     it("should throw an error if the dispatcher is missing", function() {
       expect(function() {
-        new loop.store.ConversationAppStore({mozLoop: {}});
+        new loop.store.ConversationAppStore({
+          activeRoomStore: activeRoomStore,
+          mozLoop: {}
+        });
       }).to.Throw(/dispatcher/);
     });
 
     it("should throw an error if mozLoop is missing", function() {
       expect(function() {
-        new loop.store.ConversationAppStore({dispatcher: dispatcher});
+        new loop.store.ConversationAppStore({
+          activeRoomStore: activeRoomStore,
+          dispatcher: dispatcher
+        });
       }).to.Throw(/mozLoop/);
     });
+
+    it("should start listening to events on the window object", function() {
+      var fakeWindow = {
+        addEventListener: sinon.stub()
+      };
+
+      var store = new loop.store.ConversationAppStore({
+        activeRoomStore: activeRoomStore,
+        dispatcher: dispatcher,
+        mozLoop: { getLoopPref: function() {} },
+        rootObject: fakeWindow
+      });
+
+      var eventNames = Object.getOwnPropertyNames(store._eventHandlers);
+      sinon.assert.callCount(fakeWindow.addEventListener, eventNames.length);
+      eventNames.forEach(function(eventName) {
+        sinon.assert.calledWith(fakeWindow.addEventListener, eventName,
+          store._eventHandlers[eventName]);
+      });
+    });
   });
 
   describe("#getWindowData", function() {
     var fakeWindowData, fakeGetWindowData, fakeMozLoop, store, getLoopPrefStub;
     var setLoopPrefStub;
 
     beforeEach(function() {
       fakeWindowData = {
@@ -56,31 +95,30 @@ describe("loop.store.ConversationAppStor
           }
           return null;
         },
         getLoopPref: getLoopPrefStub,
         setLoopPref: setLoopPrefStub
       };
 
       store = new loop.store.ConversationAppStore({
+        activeRoomStore: activeRoomStore,
         dispatcher: dispatcher,
         mozLoop: fakeMozLoop
       });
     });
 
     afterEach(function() {
       sandbox.restore();
     });
 
     it("should fetch the window type from the mozLoop API", function() {
       store.getWindowData(new sharedActions.GetWindowData(fakeGetWindowData));
 
-      expect(store.getStoreState()).eql({
-        windowType: "incoming"
-      });
+      expect(store.getStoreState().windowType).eql("incoming");
     });
 
     it("should have the feedback period in initial state", function() {
       getLoopPrefStub.returns(42);
 
       // Expect ms.
       expect(store.getInitialStoreState().feedbackPeriod).to.eql(42 * 1000);
     });
@@ -130,9 +168,123 @@ describe("loop.store.ConversationAppStor
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.SetupWindowData(_.extend({
             windowId: fakeGetWindowData.windowId
           }, fakeWindowData)));
       });
   });
+
+  describe("Window object event handlers", function() {
+    var store, fakeWindow;
+
+    beforeEach(function() {
+      fakeWindow = {
+        addEventListener: sinon.stub(),
+        removeEventListener: sinon.stub()
+      };
+
+      store = new loop.store.ConversationAppStore({
+        activeRoomStore: activeRoomStore,
+        dispatcher: dispatcher,
+        mozLoop: { getLoopPref: function() {} },
+        rootObject: fakeWindow
+      });
+    });
+
+    describe("#unloadHandler", function() {
+      it("should dispatch a 'WindowUnload' action when invoked", function() {
+        store.unloadHandler();
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch, new sharedActions.WindowUnload());
+      });
+
+      it("should remove all registered event handlers from the window object", function() {
+        var eventHandlers = store._eventHandlers;
+        var eventNames = Object.getOwnPropertyNames(eventHandlers);
+
+        store.unloadHandler();
+
+        sinon.assert.callCount(fakeWindow.removeEventListener, eventNames.length);
+        expect(store._eventHandlers).to.eql(null);
+        eventNames.forEach(function(eventName) {
+          sinon.assert.calledWith(fakeWindow.removeEventListener, eventName,
+            eventHandlers[eventName]);
+        });
+      });
+    });
+
+    describe("#LoopHangupNowHandler", function() {
+      beforeEach(function() {
+        sandbox.stub(loop.shared.mixins.WindowCloseMixin, "closeWindow");
+      });
+
+      it("should dispatch the correct action for windowType 'incoming'", function() {
+        store.setStoreState({ windowType: "incoming" });
+
+        store.LoopHangupNowHandler();
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch, new sharedActions.HangupCall());
+        sinon.assert.notCalled(loop.shared.mixins.WindowCloseMixin.closeWindow);
+      });
+
+      it("should dispatch the correct action for windowType 'outgoing'", function() {
+        store.setStoreState({ windowType: "outgoing" });
+
+        store.LoopHangupNowHandler();
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch, new sharedActions.HangupCall());
+        sinon.assert.notCalled(loop.shared.mixins.WindowCloseMixin.closeWindow);
+      });
+
+      it("should dispatch the correct action when a room was used", function() {
+        store.setStoreState({ windowType: "room" });
+        roomUsed = true;
+
+        store.LoopHangupNowHandler();
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch, new sharedActions.LeaveRoom());
+        sinon.assert.notCalled(loop.shared.mixins.WindowCloseMixin.closeWindow);
+      });
+
+      it("should close the window when a room was not used", function() {
+        store.setStoreState({ windowType: "room" });
+
+        store.LoopHangupNowHandler();
+
+        sinon.assert.notCalled(dispatcher.dispatch);
+        sinon.assert.calledOnce(loop.shared.mixins.WindowCloseMixin.closeWindow);
+      });
+
+      it("should close the window for all other window types", function() {
+        store.setStoreState({ windowType: "foobar" });
+
+        store.LoopHangupNowHandler();
+
+        sinon.assert.notCalled(dispatcher.dispatch);
+        sinon.assert.calledOnce(loop.shared.mixins.WindowCloseMixin.closeWindow);
+      });
+    });
+
+    describe("#socialFrameAttachedHandler", function() {
+      it("should update the store correctly to reflect the attached state", function() {
+        store.setStoreState({ chatWindowDetached: true });
+
+        store.socialFrameAttachedHandler();
+
+        expect(store.getStoreState().chatWindowDetached).to.eql(false);
+      });
+    });
+
+    describe("#socialFrameDetachedHandler", function() {
+      it("should update the store correctly to reflect the detached state", function() {
+        store.socialFrameDetachedHandler();
+
+        expect(store.getStoreState().chatWindowDetached).to.eql(true);
+      });
+    });
+  });
 });
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -513,16 +513,17 @@ describe("loop.conversationViews", funct
 
       expect(extraMessage.textContent).eql("generic_failure_with_reason2");
     });
   });
 
   describe("OngoingConversationView", function() {
     function mountTestComponent(extraProps) {
       var props = _.extend({
+        chatWindowDetached: false,
         conversationStore: conversationStore,
         dispatcher: dispatcher,
         mozLoop: {},
         matchMedia: window.matchMedia
       }, extraProps);
       return TestUtils.renderIntoDocument(
         React.createElement(loop.conversationViews.OngoingConversationView, props));
     }
@@ -558,28 +559,16 @@ describe("loop.conversationViews", funct
         video: {
           enabled: true
         }
       });
 
       expect(view.getDOMNode().querySelector(".local video")).not.eql(null);
     });
 
-    it("should dispatch a hangupCall action when the hangup button is pressed",
-      function() {
-        view = mountTestComponent();
-
-        var hangupBtn = view.getDOMNode().querySelector(".btn-hangup");
-
-        React.addons.TestUtils.Simulate.click(hangupBtn);
-
-        sinon.assert.calledWithMatch(dispatcher.dispatch,
-          sinon.match.hasOwn("name", "hangupCall"));
-      });
-
     it("should dispatch a setMute action when the audio mute button is pressed",
       function() {
         view = mountTestComponent({
           audio: {enabled: false}
         });
 
         var muteBtn = view.getDOMNode().querySelector(".btn-mute-audio");
 
@@ -633,16 +622,17 @@ describe("loop.conversationViews", funct
   });
 
   describe("CallControllerView", function() {
     var onCallTerminatedStub;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         React.createElement(loop.conversationViews.CallControllerView, {
+          chatWindowDetached: false,
           dispatcher: dispatcher,
           mozLoop: fakeMozLoop,
           onCallTerminated: onCallTerminatedStub
         }));
     }
 
     beforeEach(function() {
       onCallTerminatedStub = sandbox.stub();
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -172,16 +172,17 @@ describe("loop.conversation", function()
         mozLoop: {},
         sdkDriver: {}
       });
       roomStore = new loop.store.RoomStore(dispatcher, {
         mozLoop: navigator.mozLoop,
         activeRoomStore: activeRoomStore
       });
       conversationAppStore = new loop.store.ConversationAppStore({
+        activeRoomStore: activeRoomStore,
         dispatcher: dispatcher,
         mozLoop: navigator.mozLoop
       });
 
       loop.store.StoreMixin.register({
         conversationAppStore: conversationAppStore,
         conversationStore: conversationStore
       });
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -346,16 +346,17 @@ describe("loop.roomViews", function () {
         }
         return "test";
       };
       onCallTerminatedStub = sandbox.stub();
     });
 
     function mountTestComponent(props) {
       props = _.extend({
+        chatWindowDetached: false,
         dispatcher: dispatcher,
         roomStore: roomStore,
         mozLoop: fakeMozLoop,
         onCallTerminated: onCallTerminatedStub
       }, props);
       return TestUtils.renderIntoDocument(
         React.createElement(loop.roomViews.DesktopRoomConversationView, props));
     }
@@ -424,42 +425,16 @@ describe("loop.roomViews", function () {
       var muteBtn = view.getDOMNode().querySelector(".btn-mute-video");
 
       React.addons.TestUtils.Simulate.click(muteBtn);
 
       sinon.assert.calledWithMatch(dispatcher.dispatch,
         sinon.match.hasOwn("name", "setMute"));
     });
 
-    it("should dispatch a `LeaveRoom` action when the hangup button is pressed and the room has been used", function() {
-      view = mountTestComponent();
-
-      view.setState({used: true});
-
-      var hangupBtn = view.getDOMNode().querySelector(".btn-hangup");
-
-      React.addons.TestUtils.Simulate.click(hangupBtn);
-
-      sinon.assert.calledOnce(dispatcher.dispatch);
-      sinon.assert.calledWithExactly(dispatcher.dispatch,
-        new sharedActions.LeaveRoom());
-    });
-
-    it("should close the window when the hangup button is pressed and the room has not been used", function() {
-      view = mountTestComponent();
-
-      view.setState({used: false});
-
-      var hangupBtn = view.getDOMNode().querySelector(".btn-hangup");
-
-      React.addons.TestUtils.Simulate.click(hangupBtn);
-
-      sinon.assert.calledOnce(fakeWindow.close);
-    });
-
     describe("#componentWillUpdate", function() {
       function expectActionDispatched(component) {
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           sinon.match.instanceOf(sharedActions.SetupStreamElements));
       }
 
       it("should dispatch a `SetupStreamElements` action when the MEDIA_WAIT state is entered", function() {
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -498,27 +498,37 @@ describe("loop.shared.views", function()
         hangup: hangup,
         publishStream: publishStream
       });
 
       expect(comp.getDOMNode().querySelector("button.btn-hangup").textContent)
             .eql("foo");
     });
 
-    it("should accept a enableHangup optional prop", function() {
+    it("should accept an enableHangup optional prop", function() {
       var comp = mountTestComponent({
         enableHangup: false,
         hangup: hangup,
         publishStream: publishStream
       });
 
       expect(comp.getDOMNode().querySelector("button.btn-hangup").disabled)
             .eql(true);
     });
 
+    it("should accept a showHangup optional prop", function() {
+      var comp = mountTestComponent({
+        showHangup: false,
+        hangup: hangup,
+        publishStream: publishStream
+      });
+
+      expect(comp.getDOMNode().querySelector(".btn-hangup-entry")).to.eql(null);
+    });
+
     it("should hangup when hangup button is clicked", function() {
       var comp = mountTestComponent({
         hangup: hangup,
         publishStream: publishStream,
         audio: {enabled: true}
       });
 
       TestUtils.Simulate.click(
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -1215,16 +1215,17 @@
             React.createElement(FramedExample, {dashed: true, 
                            height: 394, 
                            onContentsRendered: conversationStores[0].forcedUpdate, 
                            summary: "Desktop ongoing conversation window", 
                            width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(OngoingConversationView, {
                   audio: { enabled: true, visible: true}, 
+                  chatWindowDetached: false, 
                   conversationStore: conversationStores[0], 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mediaConnected: true, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   remoteVideoEnabled: true, 
                   video: { enabled: true, visible: true}})
               )
@@ -1233,16 +1234,17 @@
             React.createElement(FramedExample, {dashed: true, 
                            height: 400, 
                            onContentsRendered: conversationStores[1].forcedUpdate, 
                            summary: "Desktop ongoing conversation window (medium)", 
                            width: 600}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(OngoingConversationView, {
                   audio: { enabled: true, visible: true}, 
+                  chatWindowDetached: false, 
                   conversationStore: conversationStores[1], 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mediaConnected: true, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   remoteVideoEnabled: true, 
                   video: { enabled: true, visible: true}})
               )
@@ -1250,16 +1252,17 @@
 
             React.createElement(FramedExample, {height: 600, 
                            onContentsRendered: conversationStores[2].forcedUpdate, 
                            summary: "Desktop ongoing conversation window (large)", 
                            width: 800}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(OngoingConversationView, {
                   audio: { enabled: true, visible: true}, 
+                  chatWindowDetached: false, 
                   conversationStore: conversationStores[2], 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mediaConnected: true, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   remoteVideoEnabled: true, 
                   video: { enabled: true, visible: true}})
               )
@@ -1268,16 +1271,17 @@
             React.createElement(FramedExample, {dashed: true, 
                            height: 394, 
                            onContentsRendered: conversationStores[3].forcedUpdate, 
                            summary: "Desktop ongoing conversation window - local face mute", 
                            width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(OngoingConversationView, {
                   audio: { enabled: true, visible: true}, 
+                  chatWindowDetached: false, 
                   conversationStore: conversationStores[3], 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mediaConnected: true, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   remoteVideoEnabled: true, 
                   video: { enabled: false, visible: true}})
               )
@@ -1286,16 +1290,17 @@
             React.createElement(FramedExample, {dashed: true, 
                            height: 394, 
                            onContentsRendered: conversationStores[4].forcedUpdate, 
                            summary: "Desktop ongoing conversation window - remote face mute", 
                            width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(OngoingConversationView, {
                   audio: { enabled: true, visible: true}, 
+                  chatWindowDetached: false, 
                   conversationStore: conversationStores[4], 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mediaConnected: true, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   remoteVideoEnabled: false, 
                   video: { enabled: true, visible: true}})
               )
@@ -1379,16 +1384,17 @@
 
           React.createElement(Section, {name: "DesktopRoomConversationView"}, 
             React.createElement(FramedExample, {height: 398, 
                            onContentsRendered: invitationRoomStore.activeRoomStore.forcedUpdate, 
                            summary: "Desktop room conversation (invitation, text-chat inclusion/scrollbars don't happen in real client)", 
                            width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopRoomConversationView, {
+                  chatWindowDetached: false, 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mozLoop: navigator.mozLoop, 
                   onCallTerminated: function(){}, 
                   roomState: ROOM_STATES.INIT, 
                   roomStore: invitationRoomStore})
               )
             ), 
@@ -1397,16 +1403,17 @@
                            height: 394, 
                            onContentsRendered: desktopRoomStoreLoading.activeRoomStore.forcedUpdate, 
                            summary: "Desktop room conversation (loading)", 
                            width: 298}, 
               /* Hide scrollbars here. Rotating loading div overflows and causes
                scrollbars to appear */
               React.createElement("div", {className: "fx-embedded overflow-hidden"}, 
                 React.createElement(DesktopRoomConversationView, {
+                  chatWindowDetached: false, 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mozLoop: navigator.mozLoop, 
                   onCallTerminated: function(){}, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   roomState: ROOM_STATES.HAS_PARTICIPANTS, 
                   roomStore: desktopRoomStoreLoading})
               )
@@ -1414,16 +1421,17 @@
 
             React.createElement(FramedExample, {dashed: true, 
                            height: 394, 
                            onContentsRendered: roomStore.activeRoomStore.forcedUpdate, 
                            summary: "Desktop room conversation", 
                            width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopRoomConversationView, {
+                  chatWindowDetached: false, 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mozLoop: navigator.mozLoop, 
                   onCallTerminated: function(){}, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   roomState: ROOM_STATES.HAS_PARTICIPANTS, 
                   roomStore: roomStore})
               )
@@ -1431,16 +1439,17 @@
 
             React.createElement(FramedExample, {dashed: true, 
                            height: 482, 
                            onContentsRendered: desktopRoomStoreMedium.activeRoomStore.forcedUpdate, 
                            summary: "Desktop room conversation (medium)", 
                            width: 602}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopRoomConversationView, {
+                  chatWindowDetached: false, 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mozLoop: navigator.mozLoop, 
                   onCallTerminated: function(){}, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   roomState: ROOM_STATES.HAS_PARTICIPANTS, 
                   roomStore: desktopRoomStoreMedium})
               )
@@ -1448,16 +1457,17 @@
 
             React.createElement(FramedExample, {dashed: true, 
                            height: 485, 
                            onContentsRendered: desktopRoomStoreLarge.activeRoomStore.forcedUpdate, 
                            summary: "Desktop room conversation (large)", 
                            width: 646}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopRoomConversationView, {
+                  chatWindowDetached: false, 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mozLoop: navigator.mozLoop, 
                   onCallTerminated: function(){}, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   roomState: ROOM_STATES.HAS_PARTICIPANTS, 
                   roomStore: desktopRoomStoreLarge})
               )
@@ -1465,31 +1475,33 @@
 
             React.createElement(FramedExample, {dashed: true, 
                            height: 394, 
                            onContentsRendered: desktopLocalFaceMuteRoomStore.activeRoomStore.forcedUpdate, 
                            summary: "Desktop room conversation local face-mute", 
                            width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopRoomConversationView, {
+                  chatWindowDetached: false, 
                   dispatcher: dispatcher, 
                   mozLoop: navigator.mozLoop, 
                   onCallTerminated: function(){}, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   roomStore: desktopLocalFaceMuteRoomStore})
               )
             ), 
 
             React.createElement(FramedExample, {dashed: true, 
                            height: 394, 
                            onContentsRendered: desktopRemoteFaceMuteRoomStore.activeRoomStore.forcedUpdate, 
                            summary: "Desktop room conversation remote face-mute", 
                            width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopRoomConversationView, {
+                  chatWindowDetached: false, 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mozLoop: navigator.mozLoop, 
                   onCallTerminated: function(){}, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   roomStore: desktopRemoteFaceMuteRoomStore})
               )
             )
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -1215,16 +1215,17 @@
             <FramedExample dashed={true}
                            height={394}
                            onContentsRendered={conversationStores[0].forcedUpdate}
                            summary="Desktop ongoing conversation window"
                            width={298}>
               <div className="fx-embedded">
                 <OngoingConversationView
                   audio={{ enabled: true, visible: true }}
+                  chatWindowDetached={false}
                   conversationStore={conversationStores[0]}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mediaConnected={true}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   remoteVideoEnabled={true}
                   video={{ enabled: true, visible: true }} />
               </div>
@@ -1233,16 +1234,17 @@
             <FramedExample dashed={true}
                            height={400}
                            onContentsRendered={conversationStores[1].forcedUpdate}
                            summary="Desktop ongoing conversation window (medium)"
                            width={600}>
               <div className="fx-embedded">
                 <OngoingConversationView
                   audio={{ enabled: true, visible: true }}
+                  chatWindowDetached={false}
                   conversationStore={conversationStores[1]}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mediaConnected={true}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   remoteVideoEnabled={true}
                   video={{ enabled: true, visible: true }} />
               </div>
@@ -1250,16 +1252,17 @@
 
             <FramedExample height={600}
                            onContentsRendered={conversationStores[2].forcedUpdate}
                            summary="Desktop ongoing conversation window (large)"
                            width={800}>
               <div className="fx-embedded">
                 <OngoingConversationView
                   audio={{ enabled: true, visible: true }}
+                  chatWindowDetached={false}
                   conversationStore={conversationStores[2]}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mediaConnected={true}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   remoteVideoEnabled={true}
                   video={{ enabled: true, visible: true }} />
               </div>
@@ -1268,16 +1271,17 @@
             <FramedExample dashed={true}
                            height={394}
                            onContentsRendered={conversationStores[3].forcedUpdate}
                            summary="Desktop ongoing conversation window - local face mute"
                            width={298}>
               <div className="fx-embedded">
                 <OngoingConversationView
                   audio={{ enabled: true, visible: true }}
+                  chatWindowDetached={false}
                   conversationStore={conversationStores[3]}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mediaConnected={true}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   remoteVideoEnabled={true}
                   video={{ enabled: false, visible: true }} />
               </div>
@@ -1286,16 +1290,17 @@
             <FramedExample dashed={true}
                            height={394}
                            onContentsRendered={conversationStores[4].forcedUpdate}
                            summary="Desktop ongoing conversation window - remote face mute"
                            width={298} >
               <div className="fx-embedded">
                 <OngoingConversationView
                   audio={{ enabled: true, visible: true }}
+                  chatWindowDetached={false}
                   conversationStore={conversationStores[4]}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mediaConnected={true}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   remoteVideoEnabled={false}
                   video={{ enabled: true, visible: true }} />
               </div>
@@ -1379,16 +1384,17 @@
 
           <Section name="DesktopRoomConversationView">
             <FramedExample height={398}
                            onContentsRendered={invitationRoomStore.activeRoomStore.forcedUpdate}
                            summary="Desktop room conversation (invitation, text-chat inclusion/scrollbars don't happen in real client)"
                            width={298}>
               <div className="fx-embedded">
                 <DesktopRoomConversationView
+                  chatWindowDetached={false}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mozLoop={navigator.mozLoop}
                   onCallTerminated={function(){}}
                   roomState={ROOM_STATES.INIT}
                   roomStore={invitationRoomStore} />
               </div>
             </FramedExample>
@@ -1397,16 +1403,17 @@
                            height={394}
                            onContentsRendered={desktopRoomStoreLoading.activeRoomStore.forcedUpdate}
                            summary="Desktop room conversation (loading)"
                            width={298}>
               {/* Hide scrollbars here. Rotating loading div overflows and causes
                scrollbars to appear */}
               <div className="fx-embedded overflow-hidden">
                 <DesktopRoomConversationView
+                  chatWindowDetached={false}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mozLoop={navigator.mozLoop}
                   onCallTerminated={function(){}}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   roomState={ROOM_STATES.HAS_PARTICIPANTS}
                   roomStore={desktopRoomStoreLoading} />
               </div>
@@ -1414,16 +1421,17 @@
 
             <FramedExample dashed={true}
                            height={394}
                            onContentsRendered={roomStore.activeRoomStore.forcedUpdate}
                            summary="Desktop room conversation"
                            width={298}>
               <div className="fx-embedded">
                 <DesktopRoomConversationView
+                  chatWindowDetached={false}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mozLoop={navigator.mozLoop}
                   onCallTerminated={function(){}}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   roomState={ROOM_STATES.HAS_PARTICIPANTS}
                   roomStore={roomStore} />
               </div>
@@ -1431,16 +1439,17 @@
 
             <FramedExample dashed={true}
                            height={482}
                            onContentsRendered={desktopRoomStoreMedium.activeRoomStore.forcedUpdate}
                            summary="Desktop room conversation (medium)"
                            width={602}>
               <div className="fx-embedded">
                 <DesktopRoomConversationView
+                  chatWindowDetached={false}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mozLoop={navigator.mozLoop}
                   onCallTerminated={function(){}}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   roomState={ROOM_STATES.HAS_PARTICIPANTS}
                   roomStore={desktopRoomStoreMedium} />
               </div>
@@ -1448,16 +1457,17 @@
 
             <FramedExample dashed={true}
                            height={485}
                            onContentsRendered={desktopRoomStoreLarge.activeRoomStore.forcedUpdate}
                            summary="Desktop room conversation (large)"
                            width={646}>
               <div className="fx-embedded">
                 <DesktopRoomConversationView
+                  chatWindowDetached={false}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mozLoop={navigator.mozLoop}
                   onCallTerminated={function(){}}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   roomState={ROOM_STATES.HAS_PARTICIPANTS}
                   roomStore={desktopRoomStoreLarge} />
               </div>
@@ -1465,31 +1475,33 @@
 
             <FramedExample dashed={true}
                            height={394}
                            onContentsRendered={desktopLocalFaceMuteRoomStore.activeRoomStore.forcedUpdate}
                            summary="Desktop room conversation local face-mute"
                            width={298}>
               <div className="fx-embedded">
                 <DesktopRoomConversationView
+                  chatWindowDetached={false}
                   dispatcher={dispatcher}
                   mozLoop={navigator.mozLoop}
                   onCallTerminated={function(){}}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   roomStore={desktopLocalFaceMuteRoomStore} />
               </div>
             </FramedExample>
 
             <FramedExample dashed={true}
                            height={394}
                            onContentsRendered={desktopRemoteFaceMuteRoomStore.activeRoomStore.forcedUpdate}
                            summary="Desktop room conversation remote face-mute"
                            width={298} >
               <div className="fx-embedded">
                 <DesktopRoomConversationView
+                  chatWindowDetached={false}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mozLoop={navigator.mozLoop}
                   onCallTerminated={function(){}}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   roomStore={desktopRemoteFaceMuteRoomStore} />
               </div>
             </FramedExample>
--- a/browser/modules/Chat.jsm
+++ b/browser/modules/Chat.jsm
@@ -1,25 +1,30 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 // A module for working with chat windows.
 
-this.EXPORTED_SYMBOLS = ["Chat"];
+this.EXPORTED_SYMBOLS = ["Chat", "kDefaultButtonSet"];
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
+const kNSXUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const kDefaultButtonSet = new Set(["minimize", "swap", "close"]);
+const kHiddenDefaultButtons = new Set(["minimize", "close"]);
+let gCustomButtons = new Map();
+
 // A couple of internal helper function.
 function isWindowChromeless(win) {
   // XXX - stolen from browser-social.js, but there's no obvious place to
   // put this so it can be shared.
 
   // Is this a popup window that doesn't want chrome shown?
   let docElem = win.document.documentElement;
   // extrachrome is not restored during session restore, so we need
@@ -200,9 +205,105 @@ var Chat = {
     }
     while (enumerator.hasMoreElements()) {
       let win = enumerator.getNext();
       if (!win.closed && isWindowGoodForChats(win))
         topMost = win;
     }
     return topMost;
   },
-}
+
+  /**
+   * Adds a button to the collection of custom buttons that can be added to the
+   * titlebar of a chatbox.
+   * For the button to be visible, `Chat#loadButtonSet` has to be called with
+   * the new buttons' ID in the buttonSet argument.
+   *
+   * @param  {Object} button Button object that may contain the following fields:
+   *   - {String}   id          Button identifier.
+   *   - {Function} [onBuild]   Function that returns a valid DOM node to
+   *                            represent the button.
+   *   - {Function} [onCommand] Callback function that is invoked when the DOM
+   *                            node is clicked.
+   */
+  registerButton: function(button) {
+    if (gCustomButtons.has(button.id))
+      return;
+    gCustomButtons.set(button.id, button);
+  },
+
+  /**
+   * Load a set of predefined buttons in a chatbox' titlebar.
+   *
+   * @param  {XULDOMNode} chatbox   Chatbox XUL element.
+   * @param  {Set|String} buttonSet Set of buttons to show in the titlebar. This
+   *                                may be a comma-separated string or a predefined
+   *                                set object.
+   */
+  loadButtonSet: function(chatbox, buttonSet = kDefaultButtonSet) {
+    if (!buttonSet)
+      return;
+
+    // When the buttonSet is coming from an XML attribute, it will be a string.
+    if (typeof buttonSet == "string") {
+      buttonSet = [for (button of buttonSet.split(",")) button.trim()];
+    }
+
+    // Make sure to keep the current set around.
+    chatbox.setAttribute("buttonSet", [...buttonSet].join(","));
+
+    let isUndocked = !chatbox.chatbar;
+    let document = chatbox.ownerDocument;
+    let titlebarNode = document.getAnonymousElementByAttribute(chatbox, "class",
+      "chat-titlebar");
+    let buttonsSeen = new Set();
+
+    for (let buttonId of buttonSet) {
+      buttonId = buttonId.trim();
+      buttonsSeen.add(buttonId);
+      let nodes, node;
+      if (kDefaultButtonSet.has(buttonId)) {
+        node = document.getAnonymousElementByAttribute(chatbox, "anonid", buttonId);
+        if (!node)
+          continue;
+
+        node.hidden = isUndocked && kHiddenDefaultButtons.has(buttonId) ? true : false;
+      } else if (gCustomButtons.has(buttonId)) {
+        let button = gCustomButtons.get(buttonId);
+        let buttonClass = "chat-" + buttonId;
+        // Custom buttons are not defined in the chatbox binding, thus not
+        // anonymous elements.
+        nodes = titlebarNode.getElementsByClassName(buttonClass);
+        node = nodes && nodes.length ? nodes[0] : null;
+        if (!node) {
+          // Allow custom buttons to build their own button node.
+          if (button.onBuild) {
+            node = button.onBuild(chatbox);
+          } else {
+            // We can also build a normal toolbarbutton to insert.
+            node = document.createElementNS(kNSXUL, "toolbarbutton");
+            node.classList.add(buttonClass);
+            node.classList.add("chat-toolbarbutton");
+          }
+
+          if (button.onCommand) {
+            node.addEventListener("command", e => {
+              button.onCommand(e, chatbox);
+            });
+          }
+          titlebarNode.appendChild(node);
+        }
+
+        // When the chat is undocked and the button wants to be visible then, it
+        // will be.
+        node.hidden = isUndocked && !button.visibleWhenUndocked;
+      } else {
+        Cu.reportError("Chatbox button '" + buttonId + "' could not be found!\n");
+      }
+    }
+
+    // Hide any button that is part of the default set, but not of the current set.
+    for (let button of kDefaultButtonSet) {
+      if (!buttonsSeen.has(button))
+        document.getAnonymousElementByAttribute(chatbox, "anonid", button).hidden = true;
+    }
+  }
+};
--- a/browser/themes/linux/browser.css
+++ b/browser/themes/linux/browser.css
@@ -1824,49 +1824,16 @@ toolbarbutton.chevron > .toolbarbutton-i
 }
 
 .social-panel > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 0;
 }
 
 %include ../shared/social/chat.inc.css
 
-.chat-titlebar {
-  background-color: #d9d9d9;
-  background-image: linear-gradient(@toolbarHighlight@, transparent);
-}
-
-.chat-titlebar[selected] {
-  background-color: #f0f0f0;
-}
-
-.chatbar-button {
-  -moz-appearance: none;
-  background-color: #d9d9d9;
-  background-image: linear-gradient(@toolbarHighlight@, transparent);
-}
-
-.chatbar-button > .toolbarbutton-icon {
-  -moz-margin-end: 0;
-}
-
-.chatbar-button:hover,
-.chatbar-button[open="true"] {
-  background-color: #f0f0f0;
-}
-
-.chatbar-button[activity] {
-  background-image: radial-gradient(circle farthest-corner at center 3px, rgb(233,242,252) 3%, rgba(172,206,255,0.75) 40%, rgba(87,151,201,0.5) 80%, transparent);
-}
-
-chatbox {
-  border-top-left-radius: 2.5px;
-  border-top-right-radius: 2.5px;
-}
-
 /* Customization mode */
 
 %include ../shared/customizableui/customizeMode.inc.css
 
 #main-window[customize-entered] > #tab-view-deck {
   background-image: url("chrome://browser/skin/customizableui/customizeMode-gridTexture.png"),
                     linear-gradient(to bottom, #bcbcbc, #b5b5b5);
   background-attachment: fixed;
--- a/browser/themes/osx/browser.css
+++ b/browser/themes/osx/browser.css
@@ -3425,53 +3425,16 @@ notification[value="loop-sharing-notific
   border-top-left-radius: inherit;
   border-top-right-radius: inherit;
 }
 
 /* === end of social toolbar provider menu === */
 
 %include ../shared/social/chat.inc.css
 
-.chat-titlebar {
-  background-color: #d9d9d9;
-  background-image: linear-gradient(rgba(255,255,255,.43), transparent);
-}
-
-.chat-titlebar[selected] {
-  background-color: #f0f0f0;
-}
-
-.chatbar-button {
-  background-color: #d9d9d9;
-  background-image: linear-gradient(rgba(255,255,255,.43), transparent);
-  border-top-left-radius: @toolbarbuttonCornerRadius@;
-  border-top-right-radius: @toolbarbuttonCornerRadius@;
-}
-
-.chatbar-button:hover,
-.chatbar-button[open="true"] {
-  background-color: #f0f0f0;
-}
-
-.chatbar-button[activity]:not([open]) {
-  background-image: radial-gradient(circle farthest-corner at center 2px, rgb(254,254,255) 3%, rgba(210,235,255,0.9) 12%, rgba(148,205,253,0.6) 30%, rgba(148,205,253,0.2) 70%);
-}
-
-chatbox {
-  border-top-left-radius: @toolbarbuttonCornerRadius@;
-  border-top-right-radius: @toolbarbuttonCornerRadius@;
-}
-
-window > chatbox {
-  border-top-left-radius: @toolbarbuttonCornerRadius@;
-  border-top-right-radius: @toolbarbuttonCornerRadius@;
-  border-bottom-left-radius: @toolbarbuttonCornerRadius@;
-  border-bottom-right-radius: @toolbarbuttonCornerRadius@;
-}
-
 /* Customization mode */
 
 %include ../shared/customizableui/customizeMode.inc.css
 
 #main-window[customizing] {
   background-color: rgb(178,178,178);
 }
 
--- a/browser/themes/shared/social/chat-icons.svg
+++ b/browser/themes/shared/social/chat-icons.svg
@@ -3,33 +3,47 @@
    - License, v. 2.0. If a copy of the MPL was not distributed with this
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-3 -3 16 16">
   <style>
     use:not(:target) {
       display: none;
     }
     use {
-      fill: #c1c1c1;
+      fill: #666;
+    }
+    use[id$="-hover"] {
+      fill: #4a4a4a;
     }
     use[id$="-active"] {
-      fill: #c1c1c1;
+      fill: #4a4a4a;
     }
     use[id$="-disabled"] {
-      fill: #c1c1c1;
+      fill: #666;
+    }
+    use[id$="-white"] {
+      fill: #fff;
     }
   </style>
   <defs>
     <polygon id="close-shape" points="10,1.717 8.336,0.049 5.024,3.369 1.663,0 0,1.668 3.36,5.037 0.098,8.307 1.762,9.975 5.025,6.705 8.311,10 9.975,8.332 6.688,5.037"/>
     <path id="dropdown-shape" fill-rule="evenodd" d="M9,3L4.984,7L1,3H9z"/>
-    <polygon id="expand-shape" points="10,0 4.838,0 6.506,1.669 0,8.175 1.825,10 8.331,3.494 10,5.162"/>
-    <rect id="minimize-shape" y="3.6" width="10" height="2.8"/>
+    <g id="expand-shape">
+      <path fill-rule="evenodd" d="M9.429,7.072v2.143c0,0.531-0.188,0.985-0.566,1.363c-0.377,0.377-0.832,0.565-1.363,0.565H1.929 c-0.531,0-0.986-0.188-1.363-0.565C0.188,10.2,0,9.746,0,9.214V3.643c0-0.531,0.188-0.985,0.566-1.362 c0.377-0.378,0.832-0.566,1.363-0.566h4.714c0.062,0,0.114,0.021,0.154,0.061s0.06,0.092,0.06,0.154v0.428 c0,0.063-0.02,0.114-0.06,0.154S6.705,2.572,6.643,2.572H1.929c-0.295,0-0.547,0.104-0.757,0.314S0.857,3.348,0.857,3.643v5.571 c0,0.295,0.105,0.547,0.315,0.757s0.462,0.314,0.757,0.314H7.5c0.294,0,0.547-0.104,0.757-0.314 c0.209-0.21,0.314-0.462,0.314-0.757V7.072c0-0.062,0.02-0.114,0.061-0.154c0.04-0.04,0.091-0.061,0.154-0.061h0.428 c0.062,0,0.114,0.021,0.154,0.061S9.429,7.009,9.429,7.072z"/>
+      <path fill-rule="evenodd" d="M7.07,5.82L6.179,4.93C6.127,4.878,6.101,4.818,6.101,4.75s0.026-0.128,0.079-0.18l2.594-2.594L7.648,0.852 C7.549,0.753,7.5,0.636,7.5,0.5s0.049-0.252,0.148-0.351S7.864,0,8,0h3.5c0.136,0,0.252,0.05,0.351,0.149S12,0.365,12,0.5V4 c0,0.136-0.05,0.253-0.149,0.351C11.752,4.451,11.635,4.5,11.5,4.5c-0.136,0-0.253-0.05-0.352-0.149l-1.124-1.125L7.429,5.82 c-0.052,0.052-0.112,0.079-0.18,0.079"/>
+    </g>
+    <rect id="minimize-shape" y="7.5" width="10" height="2.2"/>
+    <path id="exit-shape" fill-rule="evenodd" d="M5.01905144,3.00017279 C5.01277908,3.00005776 5.0064926,3 5.00019251,3 L1.99980749,3 C1.44371665,3 1,3.44762906 1,3.99980749 L1,7.00019251 C1,7.55628335 1.44762906,8 1.99980749,8 L5.00019251,8 C5.00649341,8 5.01277988,7.99994253 5.01905144,7.99982809 L5.01905144,8.5391818 C5.01905144,10.078915 5.37554713,10.2645548 5.81530684,9.9314625 L10.8239665,6.13769619 C11.2653143,5.80340108 11.2637262,5.26455476 10.8239665,4.93146254 L5.81530684,1.13769619 C5.37395904,0.80340108 5.01905144,0.98023404 5.01905144,1.52997693 L5.01905144,3.00017279 Z M-1,1 L4,1 L4,2 L0,2 L0,9 L4,9 L4,10.0100024 L-1,10.0100021 L-1,1 Z" />
   </defs>
   <use id="close" xlink:href="#close-shape"/>
   <use id="close-active" xlink:href="#close-shape"/>
   <use id="close-disabled" xlink:href="#close-shape"/>
+  <use id="close-hover" xlink:href="#close-shape"/>
+  <use id="exit-white" xlink:href="#exit-shape"/>
   <use id="expand" xlink:href="#expand-shape"/>
   <use id="expand-active" xlink:href="#expand-shape"/>
   <use id="expand-disabled" xlink:href="#expand-shape"/>
+  <use id="expand-hover" xlink:href="#expand-shape"/>
   <use id="minimize" xlink:href="#minimize-shape"/>
   <use id="minimize-active" xlink:href="#minimize-shape"/>
   <use id="minimize-disabled" xlink:href="#minimize-shape"/>
+  <use id="minimize-hover" xlink:href="#minimize-shape"/>
 </svg>
--- a/browser/themes/shared/social/chat.inc.css
+++ b/browser/themes/shared/social/chat.inc.css
@@ -50,78 +50,106 @@
 .chat-toolbarbutton {
   -moz-appearance: none;
   border: none;
   padding: 0 3px;
   margin: 0;
   background: none;
 }
 
-.chat-toolbarbutton:hover {
-  background-color: rgba(255,255,255,.35);
-}
-
-.chat-toolbarbutton:hover:active {
-  background-color: rgba(255,255,255,.5);
-}
-
 .chat-toolbarbutton > .toolbarbutton-text {
   display: none;
 }
 
 .chat-toolbarbutton > .toolbarbutton-icon {
   width: 16px;
   height: 16px;
 }
 
 .chat-close-button {
   list-style-image: url("chrome://browser/skin/social/chat-icons.svg#close");
 }
 
-.chat-close-button:-moz-any(:hover,:hover:active) {
+.chat-close-button:hover {
+  list-style-image: url("chrome://browser/skin/social/chat-icons.svg#close-hover");
+}
+
+.chat-close-button:hover:active {
   list-style-image: url("chrome://browser/skin/social/chat-icons.svg#close-active");
 }
 
 .chat-minimize-button {
   list-style-image: url("chrome://browser/skin/social/chat-icons.svg#minimize");
 }
 
-.chat-minimize-button:-moz-any(:hover,:hover:active) {
+.chat-minimize-button:hover {
+  list-style-image: url("chrome://browser/skin/social/chat-icons.svg#minimize-hover");
+}
+
+:hover,:hover:active) {
   list-style-image: url("chrome://browser/skin/social/chat-icons.svg#minimize-active");
 }
 
 .chat-swap-button {
   list-style-image: url("chrome://browser/skin/social/chat-icons.svg#expand");
   transform: rotate(180deg);
 }
 
-.chat-swap-button:-moz-any(:hover,:hover:active) {
+.chat-swap-button:hover {
+  list-style-image: url("chrome://browser/skin/social/chat-icons.svg#expand-hover");
+}
+
+.chat-swap-button:hover:active {
   list-style-image: url("chrome://browser/skin/social/chat-icons.svg#expand-active");
 }
 
 chatbar > chatbox > .chat-titlebar > .chat-swap-button {
   transform: none;
 }
 
+.chat-loop-hangup {
+  list-style-image: url("chrome://browser/skin/social/chat-icons.svg#exit-white");
+  background-color: #d13f1a;
+  border: 1px solid #d13f1a;
+  border-top-right-radius: 4px;
+  width: 32px;
+  height: 26px;
+  margin-top: -6px;
+  margin-bottom: -5px;
+  -moz-margin-start: 6px;
+  -moz-margin-end: -5px;
+}
+
+.chat-toolbarbutton.chat-loop-hangup:-moz-any(:hover,:hover:active) {
+  background-color: #ef6745;
+  border-color: #ef6745;
+}
+
 .chat-title {
-  font-weight: bold;
-  color: black;
+  color: #666;
   text-shadow: none;
   cursor: inherit;
 }
 
 .chat-titlebar {
-  height: 30px;
-  min-height: 30px;
+  height: 26px;
+  min-height: 26px;
   width: 100%;
   margin: 0;
-  padding: 7px 6px;
-  border: none;
-  border-bottom: 1px solid #ccc;
+  padding: 5px 4px;
+  border: 1px solid #ebebeb;
+  border-bottom: 0;
+  border-top-left-radius: 4px;
+  border-top-right-radius: 4px;
   cursor: pointer;
+  background-color: #ebebeb;
+}
+
+.chat-titlebar[selected] {
+  background-color: #f0f0f0;
 }
 
 .chat-titlebar > .notification-anchor-icon {
   margin-left: 2px;
   margin-right: 2px;
 }
 
 .chat-titlebar[minimized="true"] {
@@ -144,24 +172,37 @@ chatbar > chatbox > .chat-titlebar > .ch
 .chatbar-button {
   list-style-image: url("chrome://browser/skin/social/services-16.png");
   margin: 0;
   padding: 2px;
   height: 21px;
   width: 21px;
   border: 1px solid #ccc;
   border-bottom: none;
+  background-color: #d9d9d9;
+  background-image: linear-gradient(rgba(255,255,255,.43), transparent);
+  border-top-left-radius: 3px;
+  border-top-right-radius: 3px;
 }
 
 @media (min-resolution: 2dppx) {
   .chatbar-button {
     list-style-image: url("chrome://browser/skin/social/services-16@2x.png");
   }
 }
 
+.chatbar-button:hover,
+.chatbar-button[open="true"] {
+  background-color: #f0f0f0;
+}
+
+.chatbar-button[activity]:not([open]) {
+  background-image: radial-gradient(circle farthest-corner at center 2px, rgb(254,254,255) 3%, rgba(210,235,255,0.9) 12%, rgba(148,205,253,0.6) 30%, rgba(148,205,253,0.2) 70%);
+}
+
 .chatbar-button > .toolbarbutton-icon {
   width: 16px;
 }
 
 .chatbar-button > menupopup > .menuitem-iconic > .menu-iconic-left > .menu-iconic-icon {
   width: auto;
   height: auto;
   max-height: 16px;
@@ -187,19 +228,29 @@ chatbar > chatbox > .chat-titlebar > .ch
 }
 
 chatbar {
   -moz-margin-end: 20px;
 }
 
 chatbox {
   -moz-margin-start: 4px;
-  background-color: white;
-  border: 1px solid #ccc;
-  border-bottom: none;
+  background-color: transparent;
+}
+
+chatbar > chatbox {
+  /* Apply the same border-radius as the .chat-titlebar to make the box-shadow
+     go round nicely. */
+  border-top-left-radius: 4px;
+  border-top-right-radius: 4px;
+  box-shadow: 0 0 5px rgba(0,0,0,.3);
+  /* Offset the chatbox the same amount as the box-shadows' spread, to make it
+     visible. */
+  -moz-margin-end: 5px;
 }
 
 window > chatbox {
   -moz-margin-start: 0px;
   margin: 0px;
   border: none;
   padding: 0px;
+  border-radius: 4px;
 }
--- a/browser/themes/windows/browser.css
+++ b/browser/themes/windows/browser.css
@@ -2597,49 +2597,16 @@ notification[value="loop-sharing-notific
 }
 
 .social-panel-frame {
   border-radius: inherit;
 }
 
 %include ../shared/social/chat.inc.css
 
-.chat-titlebar {
-  background-color: #c4cfde;
-  background-image: linear-gradient(rgba(255,255,255,.5), transparent);
-}
-
-.chat-titlebar[selected] {
-  background-color: #dae3f0;
-}
-
-.chatbar-button {
-  -moz-appearance: none;
-  background-color: #c4cfde;
-  background-image: linear-gradient(rgba(255,255,255,.5), transparent);
-}
-
-.chatbar-button > .toolbarbutton-icon {
-  -moz-margin-end: 0;
-}
-
-.chatbar-button:hover,
-.chatbar-button[open="true"] {
-  background-color: #dae3f0;
-}
-
-.chatbar-button[activity]:not([open="true"]) {
-  background-image: radial-gradient(circle farthest-corner at center 3px, rgb(255,255,255) 3%, rgba(186,221,251,0.75) 40%, rgba(127,179,255,0.5) 80%, rgba(127,179,255,0.25));
-}
-
-chatbox {
-  border-top-left-radius: 2.5px;
-  border-top-right-radius: 2.5px;
-}
-
 /* Customization mode */
 
 %include ../shared/customizableui/customizeMode.inc.css
 
 /**
  * This next rule is a hack to disable subpixel anti-aliasing on all
  * labels during the customize mode transition. Subpixel anti-aliasing
  * on Windows with Direct2D layers acceleration is particularly slow to