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
authorMike de Boer <mdeboer@mozilla.com>
Wed, 30 Sep 2015 15:35:22 +0200
changeset 265690 d44a52b0faef48ef6d38df62eda63e78d391a7e7
parent 265689 8e4a248e5e2da2faeb06526fe4e29006e6a5d477
child 265691 2cfe7f8440830f074c19c36a5aed3dca4725fbaa
push id66003
push usercbook@mozilla.com
push dateFri, 02 Oct 2015 11:37:40 +0000
treeherdermozilla-inbound@3fd732d24f46 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs1184921
milestone44.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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
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
@@ -574,16 +574,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.
@@ -710,24 +711,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})
           )
         )
       );
     }
   });
 
   /**
@@ -738,16 +740,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();
     },
@@ -817,16 +820,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
@@ -574,16 +574,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.
@@ -710,24 +711,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>
       );
     }
   });
 
   /**
@@ -738,16 +740,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();
     },
@@ -817,16 +820,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
@@ -543,16 +543,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
     },
@@ -774,16 +775,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
@@ -543,16 +543,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
     },
@@ -774,16 +775,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");
 
@@ -900,16 +910,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;
@@ -1009,21 +1021,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
@@ -523,16 +523,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));
     }
@@ -568,28 +569,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");
 
@@ -643,16 +632,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
@@ -321,16 +321,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));
     }
@@ -399,42 +400,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
@@ -1217,16 +1217,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}})
               )
@@ -1235,16 +1236,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}})
               )
@@ -1252,16 +1254,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}})
               )
@@ -1270,16 +1273,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}})
               )
@@ -1288,16 +1292,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}})
               )
@@ -1383,16 +1388,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})
               )
             ), 
@@ -1418,16 +1424,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})
               )
@@ -1435,16 +1442,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})
               )
@@ -1452,16 +1460,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})
               )
@@ -1469,16 +1478,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})
               )
@@ -1486,31 +1496,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
@@ -1217,16 +1217,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>
@@ -1235,16 +1236,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>
@@ -1252,16 +1254,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>
@@ -1270,16 +1273,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>
@@ -1288,16 +1292,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>
@@ -1383,16 +1388,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>
@@ -1418,16 +1424,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>
@@ -1435,16 +1442,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>
@@ -1452,16 +1460,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>
@@ -1469,16 +1478,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>
@@ -1486,31 +1496,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
@@ -2599,49 +2599,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