Bug 1227539: Part 1 - close a chat window properly, including stopping all sharing and leaving the room. r=Standard8
☠☠ backed out by b92cd30cf34f ☠ ☠
authorMike de Boer <mdeboer@mozilla.com>
Wed, 23 Dec 2015 13:37:57 +0100
changeset 317403 860d77fbf406524dd7ad67066d92e051e9555fba
parent 317402 d43c0078a51d3d9159df1dede2fe683f6ae7ed02
child 317404 30b9e8aa695d29d815baa2b1f8c05ff2fa10df6a
push id8691
push userbmo:vivekb.balakrishnan@gmail.com
push dateWed, 23 Dec 2015 21:07:27 +0000
reviewersStandard8
bugs1227539
milestone46.0a1
Bug 1227539: Part 1 - close a chat window properly, including stopping all sharing and leaving the room. r=Standard8
browser/extensions/loop/content/modules/LoopRooms.jsm
browser/extensions/loop/content/modules/MozLoopAPI.jsm
browser/extensions/loop/content/modules/MozLoopService.jsm
browser/extensions/loop/content/panels/js/panel.js
browser/extensions/loop/content/panels/js/panel.jsx
browser/extensions/loop/content/shared/js/activeRoomStore.js
browser/extensions/loop/content/shared/js/mixins.js
browser/extensions/loop/test/mochitest/browser_fxa_login.js
browser/extensions/loop/test/mochitest/browser_mozLoop_appVersionInfo.js
browser/extensions/loop/test/mochitest/browser_mozLoop_chat.js
browser/extensions/loop/test/mochitest/browser_mozLoop_context.js
browser/extensions/loop/test/mochitest/browser_mozLoop_sharingListeners.js
browser/extensions/loop/test/mochitest/browser_mozLoop_socialShare.js
browser/extensions/loop/test/mochitest/browser_mozLoop_telemetry.js
browser/extensions/loop/test/mochitest/head.js
browser/extensions/loop/test/shared/activeRoomStore_test.js
browser/extensions/loop/test/xpcshell/test_loopapi_internal.js
--- a/browser/extensions/loop/content/modules/LoopRooms.jsm
+++ b/browser/extensions/loop/content/modules/LoopRooms.jsm
@@ -713,17 +713,17 @@ var LoopRoomsInternal = {
   open: function(roomToken) {
     let windowData = {
       roomToken: roomToken,
       type: "room"
     };
 
     eventEmitter.emit("open", roomToken);
 
-    MozLoopService.openChatWindow(windowData, () => {
+    return MozLoopService.openChatWindow(windowData, () => {
       eventEmitter.emit("close");
     });
   },
 
   /**
    * Deletes a room.
    *
    * @param {String}   roomToken The room token.
--- a/browser/extensions/loop/content/modules/MozLoopAPI.jsm
+++ b/browser/extensions/loop/content/modules/MozLoopAPI.jsm
@@ -118,17 +118,17 @@ const updateSocialProvidersCache = funct
     // Dispatch an event to content to let stores freshen-up.
     LoopAPIInternal.broadcastPushMessage("SocialProvidersChanged");
   }
 
   return gSocialProviders;
 };
 
 var gAppVersionInfo = null;
-var gBrowserSharingListenerCount = 0;
+var gBrowserSharingListeners = new Set();
 var gBrowserSharingWindows = new Set();
 var gPageListeners = null;
 var gOriginalPageListeners = null;
 var gSocialProviders = null;
 var gStringBundle = null;
 var gStubbedMessageHandlers = null;
 var gOriginalPanelHeight = null;
 const kBatchMessage = "Batch";
@@ -136,20 +136,22 @@ const kMaxLoopCount = 10;
 const kMessageName = "Loop:Message";
 const kPushMessageName = "Loop:Message:Push";
 const kPushSubscription = "pushSubscription";
 const kRoomsPushPrefix = "Rooms:";
 const kMessageHandlers = {
   /**
    * Start browser sharing, which basically means to start listening for tab
    * switches and passing the new window ID to the sender whenever that happens.
-   * 
+   *
    * @param {Object}   message Message meant for the handler function, containing
    *                           the following parameters in its `data` property:
-   *                           [ ]
+   *                           [
+   *                             {Number} windowId The window ID of the chat window
+   *                           ]
    * @param {Function} reply   Callback function, invoked with the result of this
    *                           message handler. The result will be sent back to
    *                           the senders' channel.
    */
   AddBrowserSharingListener: function(message, reply) {
     let win = Services.wm.getMostRecentWindow("navigator:browser");
     let browser = win && win.gBrowser.selectedBrowser;
     if (!win || !browser) {
@@ -164,20 +166,23 @@ const kMessageHandlers = {
       // Tab sharing is not supported yet for e10s-enabled browsers. This will
       // be fixed in bug 1137634.
       let err = new Error("Tab sharing is not supported for e10s-enabled browsers");
       MozLoopService.log.error(err);
       reply(cloneableError(err));
       return;
     }
 
+    let [windowId] = message.data;
+
     win.LoopUI.startBrowserSharing();
 
     gBrowserSharingWindows.add(Cu.getWeakReference(win));
-    ++gBrowserSharingListenerCount;
+    gBrowserSharingListeners.add(windowId);
+    reply();
   },
 
   /**
    * Associates a session-id and a call-id with a window for debugging.
    *
    * @param {Object}   message Message meant for the handler function, containing
    *                           the following parameters in its `data` property:
    *                           [
@@ -228,23 +233,24 @@ const kMessageHandlers = {
    *                             {String} subject   Subject of the email to send
    *                             {String} body      Body message of the email to send
    *                             {String} recipient Recipient email address (optional)
    *                           ]
    * @param {Function} reply   Callback function, invoked with the result of this
    *                           message handler. The result will be sent back to
    *                           the senders' channel.
    */
-  ComposeEmail: function(message) {
+  ComposeEmail: function(message, reply) {
     let [subject, body, recipient] = message.data;
     recipient = recipient || "";
     let mailtoURL = "mailto:" + encodeURIComponent(recipient) +
                     "?subject=" + encodeURIComponent(subject) +
                     "&body=" + encodeURIComponent(body);
     extProtocolSvc.loadURI(CommonUtils.makeURI(mailtoURL));
+    reply();
   },
 
   /**
    * Show a confirmation dialog with the standard - localized - 'Yes'/ 'No'
    * buttons or custom labels.
    *
    * @param {Object}   message Message meant for the handler function, containing
    *                           the following parameters in its `data` property:
@@ -358,16 +364,17 @@ const kMessageHandlers = {
       ROOM_CONTEXT_ADD: ROOM_CONTEXT_ADD,
       ROOM_CREATE: ROOM_CREATE,
       ROOM_DELETE: ROOM_DELETE,
       SHARING_ROOM_URL: SHARING_ROOM_URL,
       SHARING_STATE_CHANGE: SHARING_STATE_CHANGE,
       TWO_WAY_MEDIA_CONN_LENGTH: TWO_WAY_MEDIA_CONN_LENGTH
     });
   },
+
   /**
    * Returns the app version information for use during feedback.
    *
    * @param {Object}   message Message meant for the handler function, containing
    *                           the following parameters in its `data` property:
    *                           [ ]
    * @param {Function} reply   Callback function, invoked with the result of this
    *                           message handler. The result will be sent back to
@@ -681,19 +688,53 @@ const kMessageHandlers = {
     reply({
       email: MozLoopService.userProfile.email,
       uid: MozLoopService.userProfile.uid
     });
   },
 
   /**
    * Hangup and close all chat windows that are open.
+   *
+   * @param {Object}   message Message meant for the handler function, containing
+   *                           the following parameters in its `data` property:
+   *                           [ ]
+   * @param {Function} reply   Callback function, invoked with the result of this
+   *                           message handler. The result will be sent back to
+   *                           the senders' channel.
    */
-  HangupAllChatWindows: function() {
+  HangupAllChatWindows: function(message, reply) {
     MozLoopService.hangupAllChatWindows();
+    reply();
+  },
+
+  /**
+   * Hangup a specific chay window or room, by leaving a room, resetting the
+   * screensharing state and removing any active browser switch listeners.
+   *
+   * @param {Object}   message Message meant for the handler function, containing
+   *                           the following parameters in its `data` property:
+   *                           [
+   *                             {String} roomToken The token of the room to leave
+   *                             {Number} windowId  The window ID of the chat window
+   *                           ]
+   * @param {Function} reply   Callback function, invoked with the result of this
+   *                           message handler. The result will be sent back to
+   *                           the senders' channel.
+   */
+  HangupNow: function(message, reply) {
+    let [roomToken, windowId] = message.data;
+
+    LoopRooms.leave(roomToken);
+    MozLoopService.setScreenShareState(windowId, false);
+    LoopAPI.sendMessageToHandler({
+      name: "RemoveBrowserSharingListener",
+      data: [windowId]
+    });
+    reply();
   },
 
   /**
    * Check if the current browser has e10s enabled or not
    *
    * @param {Object}   message Message meant for the handler function, containing
    *                           the following parameters in its `data` property:
    *                           []
@@ -810,16 +851,17 @@ const kMessageHandlers = {
    * @param {Function} reply   Callback function, invoked with the result of this
    *                           message handler. The result will be sent back to
    *                           the senders' channel.
    */
   OpenNonE10sWindow: function(message, reply) {
     let win = Services.wm.getMostRecentWindow("navigator:browser");
     let url = message.data[0] ? message.data[0] : "about:home";
     win.openDialog("chrome://browser/content/", "_blank", "chrome,all,dialog=no,non-remote", url);
+    reply();
   },
 
   /**
    * Opens a URL in a new tab in the browser.
    *
    * @param {Object}   message Message meant for the handler function, containing
    *                           the following parameters in its `data` property:
    *                           [
@@ -832,36 +874,50 @@ const kMessageHandlers = {
   OpenURL: function(message, reply) {
     let url = message.data[0];
     MozLoopService.openURL(url);
     reply();
   },
 
   /**
    * Removes a listener that was previously added.
+   *
+   * @param {Object}   message Message meant for the handler function, containing
+   *                           the following parameters in its `data` property:
+   *                           [
+   *                             {Number} windowId The window ID of the chat
+   *                           ]
+   * @param {Function} reply   Callback function, invoked with the result of this
+   *                           message handler. The result will be sent back to
+   *                           the senders' channel.
    */
-  RemoveBrowserSharingListener: function() {
-    if (!gBrowserSharingListenerCount) {
+  RemoveBrowserSharingListener: function(message, reply) {
+    if (!gBrowserSharingListeners.size) {
+      reply();
       return;
     }
 
-    if (--gBrowserSharingListenerCount > 0) {
+    let [windowId] = message.data;
+    gBrowserSharingListeners.delete(windowId);
+    if (gBrowserSharingListeners.size > 0) {
       // There are still clients listening in, so keep on listening...
+      reply();
       return;
     }
 
     for (let win of gBrowserSharingWindows) {
       win = win.get();
       if (!win) {
         continue;
       }
       win.LoopUI.stopBrowserSharing();
     }
 
     gBrowserSharingWindows.clear();
+    reply();
   },
 
   "Rooms:*": function(action, message, reply) {
     LoopAPIInternal.handleObjectAPIMessage(LoopRooms, kRoomsPushPrefix,
       action, message, reply);
   },
 
   /**
@@ -937,19 +993,20 @@ const kMessageHandlers = {
    *                                               the state is being changed for.
    *                             {Boolean} active  Whether or not screen sharing
    *                                               is now active.
    *                           ]
    * @param {Function} reply   Callback function, invoked with the result of this
    *                           message handler. The result will be sent back to
    *                           the senders' channel.
    */
-  SetScreenShareState: function(message) {
+  SetScreenShareState: function(message, reply) {
     let [windowId, active] = message.data;
     MozLoopService.setScreenShareState(windowId, active);
+    reply();
   },
 
   /**
    * Share a room URL with the Social API.
    *
    * @param {Object}   message Message meant for the handler function, containing
    *                           the following parameters in its `data` property:
    *                           [
@@ -1309,16 +1366,56 @@ this.LoopAPI = Object.freeze({
   /* @see LoopAPIInternal#broadcastPushMessage */
   broadcastPushMessage: function(name, data) {
     LoopAPIInternal.broadcastPushMessage(name, data);
   },
   /* @see LoopAPIInternal#destroy */
   destroy: function() {
     LoopAPIInternal.destroy();
   },
+  /**
+   * Gateway for chrome scripts to send a message to a message handler, when
+   * using the RemotePageManager module is not an option.
+   *
+   * @param {Object}   message Message meant for the handler function, containing
+   *                           the following properties:
+   *                           - {String} name     Name of handler to send this
+   *                                               message to. See `kMessageHandlers`
+   *                                               for the available names.
+   *                           - {String} [action] Optional action name of the
+   *                                               function to call on a sub-API.
+   *                           - {Array}  data     List of arguments that the
+   *                                               handler can use.
+   * @param {Function} [reply] Callback function, invoked with the result of this
+   *                           message handler. Optional.
+   */
+  sendMessageToHandler: function(message, reply) {
+    reply = reply || function() {};
+    let handlerName = message.name;
+    let handler = kMessageHandlers[handlerName];
+    if (gStubbedMessageHandlers && gStubbedMessageHandlers[handlerName]) {
+      handler = gStubbedMessageHandlers[handlerName];
+    }
+    if (!handler) {
+      let msg = "Ouch, no message handler available for '" + handlerName + "'";
+      MozLoopService.log.error(msg);
+      reply(cloneableError(msg));
+      return;
+    }
+
+    if (!message.data) {
+      message.data = [];
+    }
+
+    if (handlerName.endsWith("*")) {
+      handler(message.action, message, reply);
+    } else {
+      handler(message, reply);
+    }
+  },
   // The following functions are only used in unit tests.
   inspect: function() {
     return [Object.create(LoopAPIInternal), Object.create(kMessageHandlers),
       gPageListeners ? [...gPageListeners] : null];
   },
   stub: function(pageListeners) {
     if (!gOriginalPageListeners) {
       gOriginalPageListeners = gPageListeners;
--- a/browser/extensions/loop/content/modules/MozLoopService.jsm
+++ b/browser/extensions/loop/content/modules/MozLoopService.jsm
@@ -948,17 +948,20 @@ var MozLoopServiceInternal = {
             let ref = chatbar.chatboxForURL.get(chatbox.src);
             chatbox = ref && ref.get() || chatbox;
           } else if (eventName == "Loop:ChatWindowClosed") {
             windowCloseCallback();
             if (conversationWindowData.type == "room") {
               // NOTE: if you add something here, please also consider if something
               //       needs to be done on the content side as well (e.g.
               //       activeRoomStore#windowUnload).
-              LoopRooms.leave(conversationWindowData.roomToken);
+              LoopAPI.sendMessageToHandler({
+                name: "HangupNow",
+                data: [conversationWindowData.roomToken, windowId]
+              });
             }
           }
         }
 
         window.addEventListener("socialFrameHide", socialFrameChanged.bind(null, "Loop:ChatWindowHidden"));
         window.addEventListener("socialFrameShow", socialFrameChanged.bind(null, "Loop:ChatWindowShown"));
         window.addEventListener("socialFrameDetached", socialFrameChanged.bind(null, "Loop:ChatWindowDetached"));
         window.addEventListener("socialFrameAttached", socialFrameChanged.bind(null, "Loop:ChatWindowAttached"));
@@ -1199,17 +1202,17 @@ var gInitializeTimerFunc = (deferredInit
 
 var gServiceInitialized = false;
 
 /**
  * Public API
  */
 this.MozLoopService = {
   _DNSService: gDNSService,
-  _activeScreenShares: [],
+  _activeScreenShares: new Set(),
 
   get channelIDs() {
     // Channel ids that will be registered with the PushServer for notifications
     return {
       roomsFxA: "6add272a-d316-477c-8335-f00f73dfde71",
       roomsGuest: "19d3f799-a8f3-4328-9822-b7cd02765832"
     };
   },
@@ -1917,26 +1920,25 @@ this.MozLoopService = {
    * be reflected on the toolbar button.
    *
    * @param {String} windowId The id of the conversation window the state
    *                          is being changed for.
    * @param {Boolean} active  Whether or not screen sharing is now active.
    */
   setScreenShareState: function(windowId, active) {
     if (active) {
-      this._activeScreenShares.push(windowId);
+      this._activeScreenShares.add(windowId);
     } else {
-      var index = this._activeScreenShares.indexOf(windowId);
-      if (index != -1) {
-        this._activeScreenShares.splice(index, 1);
+      if (this._activeScreenShares.has(windowId)) {
+        this._activeScreenShares.delete(windowId);
       }
     }
 
     MozLoopServiceInternal.notifyStatusChanged();
   },
 
   /**
    * Returns true if screen sharing is active in at least one window.
    */
   get screenShareActive() {
-    return this._activeScreenShares.length > 0;
+    return this._activeScreenShares.size > 0;
   }
 };
--- a/browser/extensions/loop/content/panels/js/panel.js
+++ b/browser/extensions/loop/content/panels/js/panel.js
@@ -814,17 +814,17 @@ loop.panel = (function(_, mozL10n) {
       // We would use onDocumentHidden to null out the data ready for the next
       // opening. However, this seems to cause an awkward glitch in the display
       // when opening the panel, and it seems cleaner just to update the data
       // even if there's a small delay.
 
       loop.request("GetSelectedTabMetadata").then(function(metadata) {
         // Bail out when the component is not mounted (anymore).
         // This occurs during test runs. See bug 1174611 for more info.
-        if (!this.isMounted()) {
+        if (!this.isMounted() || !metadata) {
           return;
         }
 
         var previewImage = metadata.favicon || "";
         var description = metadata.title || metadata.description;
         var url = metadata.url;
         this.setState({
           previewImage: previewImage,
--- a/browser/extensions/loop/content/panels/js/panel.jsx
+++ b/browser/extensions/loop/content/panels/js/panel.jsx
@@ -814,17 +814,17 @@ loop.panel = (function(_, mozL10n) {
       // We would use onDocumentHidden to null out the data ready for the next
       // opening. However, this seems to cause an awkward glitch in the display
       // when opening the panel, and it seems cleaner just to update the data
       // even if there's a small delay.
 
       loop.request("GetSelectedTabMetadata").then(function(metadata) {
         // Bail out when the component is not mounted (anymore).
         // This occurs during test runs. See bug 1174611 for more info.
-        if (!this.isMounted()) {
+        if (!this.isMounted() || !metadata) {
           return;
         }
 
         var previewImage = metadata.favicon || "";
         var description = metadata.title || metadata.description;
         var url = metadata.url;
         this.setState({
           previewImage: previewImage,
--- a/browser/extensions/loop/content/shared/js/activeRoomStore.js
+++ b/browser/extensions/loop/content/shared/js/activeRoomStore.js
@@ -974,17 +974,17 @@ loop.store.ActiveRoomStore = (function()
     },
 
     /**
      * Ends an active screenshare session.
      */
     endScreenShare: function() {
       if (this._browserSharingListener) {
         // Remove the browser sharing listener as we don't need it now.
-        loop.request("RemoveBrowserSharingListener");
+        loop.request("RemoveBrowserSharingListener", this.getStoreState().windowId);
         loop.unsubscribe("BrowserSwitch", this._browserSharingListener);
         this._browserSharingListener = null;
       }
 
       if (this._sdkDriver.endScreenShare()) {
         this.dispatchAction(new sharedActions.ScreenSharingState({
           state: SCREEN_SHARE_STATES.INACTIVE
         }));
@@ -1112,23 +1112,18 @@ loop.store.ActiveRoomStore = (function()
         });
         return;
       }
 
       if (loop.standaloneMedia) {
         loop.standaloneMedia.multiplexGum.reset();
       }
 
-      var requests = [
-        ["SetScreenShareState", this.getStoreState().windowId, false]
-      ];
-
       if (this._browserSharingListener) {
         // Remove the browser sharing listener as we don't need it now.
-        requests.push(["RemoveBrowserSharingListener"]);
         loop.unsubscribe("BrowserSwitch", this._browserSharingListener);
         this._browserSharingListener = null;
       }
 
       // We probably don't need to end screen share separately, but lets be safe.
       this._sdkDriver.disconnectSession();
 
       // Reset various states.
@@ -1140,27 +1135,25 @@ loop.store.ActiveRoomStore = (function()
       });
       this.setStoreState(newStoreState);
 
       if (this._timeout) {
         clearTimeout(this._timeout);
         delete this._timeout;
       }
 
-      if (!failedJoinRequest &&
-          (this._storeState.roomState === ROOM_STATES.JOINING ||
-           this._storeState.roomState === ROOM_STATES.JOINED ||
-           this._storeState.roomState === ROOM_STATES.SESSION_CONNECTED ||
-           this._storeState.roomState === ROOM_STATES.HAS_PARTICIPANTS)) {
-        requests.push(["Rooms:Leave", this._storeState.roomToken,
-          this._storeState.sessionToken]);
+      // If we're not going to close the window, we can hangup the call ourselves.
+      // NOTE: when the window _is_ closed, hanging up the call is performed by
+      //       MozLoopService, because we can't get a message across to LoopAPI
+      //       in time whilst a window is closing.
+      if (nextState === ROOM_STATES.FAILED && !failedJoinRequest) {
+        loop.request("HangupNow", this._storeState.roomToken,
+          this._storeState.windowId);
       }
 
-      loop.requestMulti.apply(null, requests);
-
       this.setStoreState({ roomState: nextState });
     },
 
     /**
      * When feedback is complete, we go back to the ready state, rather than
      * init or gather, as we don't need to get the data from the server again.
      */
     feedbackComplete: function() {
--- a/browser/extensions/loop/content/shared/js/mixins.js
+++ b/browser/extensions/loop/content/shared/js/mixins.js
@@ -388,16 +388,19 @@ loop.shared.mixins = (function() {
         options.loop = options.loop || false;
 
         this._ensureAudioStopped();
         this._getAudioBlob(name, function(error, blob) {
           if (error) {
             console.error(error);
             return;
           }
+          if (!blob) {
+            return;
+          }
 
           var url = URL.createObjectURL(blob);
           this.audio = new Audio(url);
           this.audio.loop = options.loop;
           this.audio.play();
         }.bind(this));
       }.bind(this));
     },
@@ -406,17 +409,17 @@ loop.shared.mixins = (function() {
       this._canPlay().then(function(canPlay) {
         if (!canPlay) {
           callback();
           return;
         }
 
         if (this._isLoopDesktop()) {
           loop.request("GetAudioBlob", name).then(function(result) {
-            if (result.isError) {
+            if (result && result.isError) {
               callback(result);
               return;
             }
             callback(null, result);
           });
           return;
         }
 
--- a/browser/extensions/loop/test/mochitest/browser_fxa_login.js
+++ b/browser/extensions/loop/test/mochitest/browser_fxa_login.js
@@ -3,17 +3,16 @@
 
 /**
  * Test FxA logins with Loop.
  */
 
 "use strict";
 
 const BASE_URL = Services.prefs.getCharPref("loop.server");
-const { LoopAPI } = Cu.import("chrome://loop/content/modules/MozLoopAPI.jsm", {});
 
 function* checkFxA401() {
   let err = MozLoopService.errors.get("login");
   is(err.code, 401, "Check error code");
   is(err.friendlyMessage, getLoopString("could_not_authenticate"),
      "Check friendlyMessage");
   is(err.friendlyDetails, getLoopString("password_changed_question"),
      "Check friendlyDetails");
--- a/browser/extensions/loop/test/mochitest/browser_mozLoop_appVersionInfo.js
+++ b/browser/extensions/loop/test/mochitest/browser_mozLoop_appVersionInfo.js
@@ -1,13 +1,12 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 "use strict";
 
-const { LoopAPI } = Cu.import("chrome://loop/content/modules/MozLoopAPI.jsm", {});
 var [, gHandlers] = LoopAPI.inspect();
 
 add_task(function* test_mozLoop_appVersionInfo() {
   let appVersionInfo;
   gHandlers.GetAppVersionInfo({}, result => appVersionInfo = result);
 
   Assert.ok(appVersionInfo, "should have appVersionInfo");
 
--- a/browser/extensions/loop/test/mochitest/browser_mozLoop_chat.js
+++ b/browser/extensions/loop/test/mochitest/browser_mozLoop_chat.js
@@ -13,32 +13,54 @@ function isAnyLoopChatOpen() {
 
 add_task(MozLoopService.initialize.bind(MozLoopService));
 
 add_task(function* test_mozLoop_nochat() {
   Assert.ok(!isAnyLoopChatOpen(), "there should be no chat windows yet");
 });
 
 add_task(function* test_mozLoop_openchat() {
-  let windowData = {
-    roomToken: "fake1234",
-    type: "room"
-  };
+  let windowId = LoopRooms.open("fake1234");
+  Assert.ok(isAnyLoopChatOpen(), "chat window should have been opened");
 
-  LoopRooms.open(windowData);
-  Assert.ok(isAnyLoopChatOpen(), "chat window should have been opened");
+  let chatboxesForRoom = [...Chat.chatboxes].filter(chatbox => {
+    return chatbox.src == MozLoopServiceInternal.getChatURL(windowId);
+  });
+  Assert.strictEqual(chatboxesForRoom.length, 1, "Only one chatbox should be open");
 });
 
 add_task(function* test_mozLoop_hangupAllChatWindows() {
-  let windowData = {
-    roomToken: "fake2345",
-    type: "room"
-  };
-
-  LoopRooms.open(windowData);
+  LoopRooms.open("fake2345");
 
   yield promiseWaitForCondition(() => {
     MozLoopService.hangupAllChatWindows();
     return !isAnyLoopChatOpen();
   });
 
   Assert.ok(!isAnyLoopChatOpen(), "chat window should have been closed");
 });
+
+add_task(function* test_mozLoop_hangupOnClose() {
+  let roomToken = "fake1234";
+
+  let hangupNowCalls = [];
+  LoopAPI.stubMessageHandlers({
+    HangupNow: function(message, reply) {
+      hangupNowCalls.push(message);
+      reply();
+    }
+  });
+
+  let windowId = LoopRooms.open(roomToken);
+
+  yield promiseWaitForCondition(() => {
+    MozLoopService.hangupAllChatWindows();
+    return !isAnyLoopChatOpen();
+  });
+
+  Assert.strictEqual(hangupNowCalls.length, 1, "HangupNow handler should be called once");
+  Assert.deepEqual(hangupNowCalls.pop(), {
+    name: "HangupNow",
+    data: [roomToken, windowId]
+  }, "Messages should be the same");
+
+  LoopAPI.restore();
+});
--- a/browser/extensions/loop/test/mochitest/browser_mozLoop_context.js
+++ b/browser/extensions/loop/test/mochitest/browser_mozLoop_context.js
@@ -2,17 +2,16 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /*
  * This file contains tests for various context-in-conversations helpers in
  * LoopUI and Loop API.
  */
 "use strict";
 
-const { LoopAPI } = Cu.import("chrome://loop/content/modules/MozLoopAPI.jsm", {});
 var [, gHandlers] = LoopAPI.inspect();
 
 function promiseGetMetadata() {
   return new Promise(resolve => gHandlers.GetSelectedTabMetadata({}, resolve));
 }
 
 add_task(function* test_mozLoop_getSelectedTabMetadata() {
   let metadata = yield promiseGetMetadata();
--- a/browser/extensions/loop/test/mochitest/browser_mozLoop_sharingListeners.js
+++ b/browser/extensions/loop/test/mochitest/browser_mozLoop_sharingListeners.js
@@ -1,35 +1,38 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /*
  * This file contains tests for the window.LoopUI active tab trackers.
  */
 "use strict";
 
-const { LoopAPI } = Cu.import("chrome://loop/content/modules/MozLoopAPI.jsm", {});
 var [, gHandlers] = LoopAPI.inspect();
 
 var handlers = [
   { windowId: null }, { windowId: null }
 ];
 
+var listenerCount = 41;
+var listenerIds = [];
+
 function promiseWindowId() {
   return new Promise(resolve => {
     LoopAPI.stub([{
       sendAsyncMessage: function(messageName, data) {
         let [name, windowId] = data;
         if (name == "BrowserSwitch") {
           LoopAPI.restore();
           resolve(windowId);
         }
       }
     }]);
-    gHandlers.AddBrowserSharingListener({}, () => {});
+    listenerIds.push(++listenerCount);
+    gHandlers.AddBrowserSharingListener({ data: [listenerCount] }, () => {});
   });
 }
 
 function* promiseWindowIdReceivedOnAdd(handler) {
   handler.windowId = yield promiseWindowId();
 }
 
 var createdTabs = [];
@@ -73,17 +76,17 @@ add_task(function* test_singleListener()
   // Check that a new tab updates the window id.
   yield promiseWindowIdReceivedNewTab([handlers[0]]);
 
   let newWindowId = handlers[0].windowId;
 
   Assert.notEqual(initialWindowId, newWindowId, "Tab contentWindow IDs shouldn't be the same");
 
   // Now remove the listener.
-  gHandlers.RemoveBrowserSharingListener();
+  gHandlers.RemoveBrowserSharingListener({ data: [listenerIds.pop()] }, function() {});
 
   yield removeTabs();
 });
 
 add_task(function* test_multipleListener() {
   yield promiseWindowIdReceivedOnAdd(handlers[0]);
 
   let initialWindowId0 = handlers[0].windowId;
@@ -103,30 +106,30 @@ add_task(function* test_multipleListener
   let newWindowId0 = handlers[0].windowId;
   let newWindowId1 = handlers[1].windowId;
 
   Assert.ok(newWindowId0, "windowId should not be null anymore");
   Assert.equal(newWindowId0, newWindowId1, "Listeners should have the same windowId");
   Assert.notEqual(initialWindowId0, newWindowId0, "Tab contentWindow IDs shouldn't be the same");
 
   // Now remove the first listener.
-  gHandlers.RemoveBrowserSharingListener();
+  gHandlers.RemoveBrowserSharingListener({ data: [listenerIds.pop()] }, function() {});
 
   // Check that a new tab updates the window id.
   yield promiseWindowIdReceivedNewTab([handlers[1]]);
 
   let nextWindowId0 = handlers[0].windowId;
   let nextWindowId1 = handlers[1].windowId;
 
   Assert.ok(nextWindowId0, "windowId should not be null anymore");
   Assert.equal(newWindowId0, nextWindowId0, "First listener shouldn't have updated");
   Assert.notEqual(newWindowId1, nextWindowId1, "Second listener should have updated");
 
   // Cleanup.
-  gHandlers.RemoveBrowserSharingListener();
+  gHandlers.RemoveBrowserSharingListener({ data: [listenerIds.pop()] }, function() {});
 
   yield removeTabs();
 });
 
 add_task(function* test_infoBar() {
   const kNSXUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
   const kBrowserSharingNotificationId = "loop-sharing-notification";
   const kPrefBrowserSharingInfoBar = "loop.browserSharing.showInfoBar";
@@ -173,12 +176,14 @@ add_task(function* test_infoBar() {
   getInfoBar().querySelector(".notification-button-default").click();
   Assert.equal(getInfoBar(), null, "The notification should be hidden now");
 
   gBrowser.selectedIndex = Array.indexOf(gBrowser.tabs, createdTabs[1]);
 
   Assert.equal(getInfoBar(), null, "The notification should still be hidden");
 
   // Cleanup.
-  gHandlers.RemoveBrowserSharingListener();
+  for (let listenerId of listenerIds) {
+    gHandlers.RemoveBrowserSharingListener({ data: [listenerId] }, function() {});
+  }
   yield removeTabs();
   Services.prefs.clearUserPref(kPrefBrowserSharingInfoBar);
 });
--- a/browser/extensions/loop/test/mochitest/browser_mozLoop_socialShare.js
+++ b/browser/extensions/loop/test/mochitest/browser_mozLoop_socialShare.js
@@ -1,15 +1,14 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 "use strict";
 
 Cu.import("resource://gre/modules/Promise.jsm");
 const { SocialService } = Cu.import("resource://gre/modules/SocialService.jsm", {});
-const { LoopAPI } = Cu.import("chrome://loop/content/modules/MozLoopAPI.jsm", {});
 
 var [, gHandlers] = LoopAPI.inspect();
 
 const kShareProvider = {
   name: "provider 1",
   origin: "https://example.com",
   iconURL: "https://example.com/browser/browser/base/content/test/general/moz.png",
   shareURL: "https://example.com/browser/browser/base/content/test/social/social_sidebar_empty.html"
--- a/browser/extensions/loop/test/mochitest/browser_mozLoop_telemetry.js
+++ b/browser/extensions/loop/test/mochitest/browser_mozLoop_telemetry.js
@@ -2,17 +2,16 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /*
  * This file contains tests for the mozLoop telemetry API.
  */
 
 "use strict";
 
-const { LoopAPI } = Cu.import("chrome://loop/content/modules/MozLoopAPI.jsm", {});
 var [, gHandlers] = LoopAPI.inspect();
 var gConstants;
 gHandlers.GetAllConstants({}, constants => gConstants = constants);
 
 /**
  * Enable local telemetry recording for the duration of the tests.
  */
 add_task(function* test_initialize() {
--- a/browser/extensions/loop/test/mochitest/head.js
+++ b/browser/extensions/loop/test/mochitest/head.js
@@ -4,16 +4,17 @@
 "use strict";
 
 const HAWK_TOKEN_LENGTH = 64;
 const {
   LOOP_SESSION_TYPE,
   MozLoopServiceInternal,
   MozLoopService
 } = Cu.import("chrome://loop/content/modules/MozLoopService.jsm", {});
+const { LoopAPI } = Cu.import("chrome://loop/content/modules/MozLoopAPI.jsm", {});
 const { LoopRooms } = Cu.import("chrome://loop/content/modules/LoopRooms.jsm", {});
 
 // Cache this value only once, at the beginning of a
 // test run, so that it doesn't pick up the offline=true
 // if offline mode is requested multiple times in a test run.
 const WAS_OFFLINE = Services.io.offline;
 
 function promisePanelLoaded() {
@@ -63,17 +64,17 @@ function promisePanelLoaded() {
       }
     });
   });
 }
 
 function waitForCondition(condition, nextTest, errorMsg) {
   var tries = 0;
   var interval = setInterval(function() {
-    if (tries >= 30) {
+    if (tries >= 100) {
       ok(false, errorMsg);
       moveOn();
     }
     var conditionPassed;
     try {
       conditionPassed = condition();
     } catch (e) {
       ok(false, e + "\n" + e.stack);
--- a/browser/extensions/loop/test/shared/activeRoomStore_test.js
+++ b/browser/extensions/loop/test/shared/activeRoomStore_test.js
@@ -22,23 +22,23 @@ describe("loop.store.ActiveRoomStore", f
     clock = sandbox.useFakeTimers();
 
     LoopMochaUtils.stubLoopRequest(requestStubs = {
       GetLoopPref: sinon.stub(),
       GetSelectedTabMetadata: sinon.stub(),
       SetLoopPref: sinon.stub(),
       AddConversationContext: sinon.stub(),
       AddBrowserSharingListener: sinon.stub().returns(42),
+      HangupNow: sinon.stub(),
       RemoveBrowserSharingListener: sinon.stub(),
       "Rooms:Get": sinon.stub().returns({
         roomUrl: "http://invalid"
       }),
       "Rooms:Join": sinon.stub().returns({}),
       "Rooms:RefreshMembership": sinon.stub().returns({ expires: 42 }),
-      "Rooms:Leave": sinon.stub(),
       "Rooms:SendConnectionStatus": sinon.stub(),
       "Rooms:PushSubscription": sinon.stub(),
       SetScreenShareState: sinon.stub(),
       GetActiveTabWindowId: sandbox.stub().returns(42),
       GetSocialShareProviders: sinon.stub().returns([]),
       TelemetryAddValue: sinon.stub()
     });
 
@@ -90,17 +90,18 @@ describe("loop.store.ActiveRoomStore", f
     beforeEach(function() {
       sandbox.stub(console, "error");
 
       fakeError = new Error("fake");
 
       store.setStoreState({
         roomState: ROOM_STATES.JOINED,
         roomToken: "fakeToken",
-        sessionToken: "1627384950"
+        sessionToken: "1627384950",
+        windowId: "42"
       });
     });
 
     it("should log the error", function() {
       store.roomFailure(new sharedActions.RoomFailure({
         error: fakeError,
         failedJoinRequest: false
       }));
@@ -173,28 +174,16 @@ describe("loop.store.ActiveRoomStore", f
       store.roomFailure(new sharedActions.RoomFailure({
         error: fakeError,
         failedJoinRequest: false
       }));
 
       sinon.assert.calledOnce(fakeMultiplexGum.reset);
     });
 
-    it("should set screen sharing inactive", function() {
-      store.setStoreState({ windowId: "1234" });
-
-      store.roomFailure(new sharedActions.RoomFailure({
-        error: fakeError,
-        failedJoinRequest: false
-      }));
-
-      sinon.assert.calledOnce(requestStubs.SetScreenShareState);
-      sinon.assert.calledWithExactly(requestStubs.SetScreenShareState, "1234", false);
-    });
-
     it("should disconnect from the servers via the sdk", function() {
       store.roomFailure(new sharedActions.RoomFailure({
         error: fakeError,
         failedJoinRequest: false
       }));
 
       sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
     });
@@ -207,46 +196,49 @@ describe("loop.store.ActiveRoomStore", f
         error: fakeError,
         failedJoinRequest: false
       }));
 
       sinon.assert.calledOnce(clearTimeout);
     });
 
     it("should remove the sharing listener", function() {
+      sandbox.stub(loop, "unsubscribe");
+
       // Setup the listener.
       store.startBrowserShare(new sharedActions.StartBrowserShare());
 
       // Now simulate room failure.
       store.roomFailure(new sharedActions.RoomFailure({
         error: fakeError,
         failedJoinRequest: false
       }));
 
-      sinon.assert.calledOnce(requestStubs.RemoveBrowserSharingListener);
+      sinon.assert.calledOnce(loop.unsubscribe);
+      sinon.assert.calledWith(loop.unsubscribe, "BrowserSwitch");
     });
 
-    it("should call mozLoop.rooms.leave", function() {
+    it("should call 'HangupNow' Loop API", function() {
       store.roomFailure(new sharedActions.RoomFailure({
         error: fakeError,
         failedJoinRequest: false
       }));
 
-      sinon.assert.calledOnce(requestStubs["Rooms:Leave"]);
-      sinon.assert.calledWithExactly(requestStubs["Rooms:Leave"],
-        "fakeToken", "1627384950");
+      sinon.assert.calledOnce(requestStubs["HangupNow"]);
+      sinon.assert.calledWithExactly(requestStubs["HangupNow"],
+        "fakeToken", "42");
     });
 
-    it("should not call mozLoop.rooms.leave if failedJoinRequest is true", function() {
+    it("should not call 'HangupNow' Loop API if failedJoinRequest is true", function() {
       store.roomFailure(new sharedActions.RoomFailure({
         error: fakeError,
         failedJoinRequest: true
       }));
 
-      sinon.assert.notCalled(requestStubs["Rooms:Leave"]);
+      sinon.assert.notCalled(requestStubs["HangupNow"]);
     });
   });
 
   describe("#retryAfterRoomFailure", function() {
     beforeEach(function() {
       sandbox.stub(console, "error");
     });
 
@@ -1210,17 +1202,18 @@ describe("loop.store.ActiveRoomStore", f
 
   describe("#connectionFailure", function() {
     var connectionFailureAction;
 
     beforeEach(function() {
       store.setStoreState({
         roomState: ROOM_STATES.JOINED,
         roomToken: "fakeToken",
-        sessionToken: "1627384950"
+        sessionToken: "1627384950",
+        windowId: "42"
       });
 
       connectionFailureAction = new sharedActions.ConnectionFailure({
         reason: "FAIL"
       });
     });
 
     it("should store the failure reason", function() {
@@ -1230,56 +1223,50 @@ describe("loop.store.ActiveRoomStore", f
     });
 
     it("should reset the multiplexGum", function() {
       store.connectionFailure(connectionFailureAction);
 
       sinon.assert.calledOnce(fakeMultiplexGum.reset);
     });
 
-    it("should set screen sharing inactive", function() {
-      store.setStoreState({ windowId: "1234" });
-
-      store.connectionFailure(connectionFailureAction);
-
-      sinon.assert.calledOnce(requestStubs.SetScreenShareState);
-      sinon.assert.calledWithExactly(requestStubs.SetScreenShareState, "1234", false);
-    });
-
     it("should disconnect from the servers via the sdk", function() {
       store.connectionFailure(connectionFailureAction);
 
       sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
     });
 
     it("should clear any existing timeout", function() {
       sandbox.stub(window, "clearTimeout");
       store._timeout = {};
 
       store.connectionFailure(connectionFailureAction);
 
       sinon.assert.calledOnce(clearTimeout);
     });
 
-    it("should call mozLoop.rooms.leave", function() {
+    it("should call 'HangupNow' Loop API", function() {
       store.connectionFailure(connectionFailureAction);
 
-      sinon.assert.calledOnce(requestStubs["Rooms:Leave"]);
-      sinon.assert.calledWithExactly(requestStubs["Rooms:Leave"],
-        "fakeToken", "1627384950");
+      sinon.assert.calledOnce(requestStubs["HangupNow"]);
+      sinon.assert.calledWithExactly(requestStubs["HangupNow"],
+        "fakeToken", "42");
     });
 
     it("should remove the sharing listener", function() {
+      sandbox.stub(loop, "unsubscribe");
+
       // Setup the listener.
       store.startBrowserShare(new sharedActions.StartBrowserShare());
 
       // Now simulate connection failure.
       store.connectionFailure(connectionFailureAction);
 
-      sinon.assert.calledOnce(requestStubs.RemoveBrowserSharingListener);
+      sinon.assert.calledOnce(loop.unsubscribe);
+      sinon.assert.calledWith(loop.unsubscribe, "BrowserSwitch");
     });
 
     it("should set the state to `FAILED`", function() {
       store.connectionFailure(connectionFailureAction);
 
       expect(store.getStoreState().roomState).eql(ROOM_STATES.FAILED);
     });
 
@@ -1815,43 +1802,42 @@ describe("loop.store.ActiveRoomStore", f
       sandbox.stub(window, "clearTimeout");
       store._timeout = {};
 
       store.windowUnload();
 
       sinon.assert.calledOnce(clearTimeout);
     });
 
-    it("should call mozLoop.rooms.leave", function() {
+    it("should not call 'HangupNow' Loop API", function() {
       store.windowUnload();
 
-      sinon.assert.calledOnce(requestStubs["Rooms:Leave"]);
-      sinon.assert.calledWithExactly(requestStubs["Rooms:Leave"],
-        "fakeToken", "1627384950");
+      sinon.assert.notCalled(requestStubs["HangupNow"]);
     });
 
-    it("should call mozLoop.rooms.leave if the room state is JOINING",
+    it("should call not call 'HangupNow' Loop API if the room state is JOINING",
       function() {
         store.setStoreState({ roomState: ROOM_STATES.JOINING });
 
         store.windowUnload();
 
-        sinon.assert.calledOnce(requestStubs["Rooms:Leave"]);
-        sinon.assert.calledWithExactly(requestStubs["Rooms:Leave"],
-          "fakeToken", "1627384950");
+        sinon.assert.notCalled(requestStubs["HangupNow"]);
       });
 
     it("should remove the sharing listener", function() {
+      sandbox.stub(loop, "unsubscribe");
+
       // Setup the listener.
       store.startBrowserShare(new sharedActions.StartBrowserShare());
 
       // Now unload the window.
       store.windowUnload();
 
-      sinon.assert.calledOnce(requestStubs.RemoveBrowserSharingListener);
+      sinon.assert.calledOnce(loop.unsubscribe);
+      sinon.assert.calledWith(loop.unsubscribe, "BrowserSwitch");
     });
 
     it("should set the state to CLOSING", function() {
       store.windowUnload();
 
       expect(store._storeState.roomState).eql(ROOM_STATES.CLOSING);
     });
   });
@@ -1881,32 +1867,33 @@ describe("loop.store.ActiveRoomStore", f
       sandbox.stub(window, "clearTimeout");
       store._timeout = {};
 
       store.leaveRoom();
 
       sinon.assert.calledOnce(clearTimeout);
     });
 
-    it("should call mozLoop.rooms.leave", function() {
+    it("should not call 'HangupNow' Loop API", function() {
       store.leaveRoom();
 
-      sinon.assert.calledOnce(requestStubs["Rooms:Leave"]);
-      sinon.assert.calledWithExactly(requestStubs["Rooms:Leave"],
-        "fakeToken", "1627384950");
+      sinon.assert.notCalled(requestStubs["HangupNow"]);
     });
 
     it("should remove the sharing listener", function() {
+      sandbox.stub(loop, "unsubscribe");
+
       // Setup the listener.
       store.startBrowserShare(new sharedActions.StartBrowserShare());
 
       // Now leave the room.
       store.leaveRoom();
 
-      sinon.assert.calledOnce(requestStubs.RemoveBrowserSharingListener);
+      sinon.assert.calledOnce(loop.unsubscribe);
+      sinon.assert.calledWith(loop.unsubscribe, "BrowserSwitch");
     });
 
     it("should set the state to ENDED", function() {
       store.leaveRoom();
 
       expect(store._storeState.roomState).eql(ROOM_STATES.ENDED);
     });
 
--- a/browser/extensions/loop/test/xpcshell/test_loopapi_internal.js
+++ b/browser/extensions/loop/test/xpcshell/test_loopapi_internal.js
@@ -55,14 +55,48 @@ add_test(function test_handleMessage() {
     Assert.strictEqual(result, true);
     callCount++;
   });
   Assert.strictEqual(callCount, 2, "The reply handler should've been called");
 
   run_next_test();
 });
 
+add_test(function test_sendMessageToHandler() {
+  // Testing error branches.
+  LoopAPI.sendMessageToHandler({
+    name: "WellThisDoesNotExist"
+  }, err => {
+    Assert.ok(err.isError, "An error should be returned");
+    Assert.strictEqual(err.message,
+      "Ouch, no message handler available for 'WellThisDoesNotExist'",
+      "Error messages should match");
+  });
+
+  // Testing correct flow branches.
+  let hangupNowCalls = [];
+  LoopAPI.stubMessageHandlers({
+    HangupNow: function(message, reply) {
+      hangupNowCalls.push(message);
+      reply();
+    }
+  });
+
+  let message = {
+    name: "HangupNow",
+    data: ["fakeToken", 42]
+  };
+  LoopAPI.sendMessageToHandler(message);
+
+  Assert.strictEqual(hangupNowCalls.length, 1, "HangupNow handler should be called once");
+  Assert.deepEqual(hangupNowCalls.pop(), message, "Messages should be the same");
+
+  LoopAPI.restore();
+
+  run_next_test();
+});
+
 function run_test() {
   do_register_cleanup(function() {
     LoopAPI.destroy();
   });
   run_next_test();
 }