Bug 1208466 - Part 2. If an owner of a Loop link clicks their own link and join, make it open the conversation window. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Mon, 28 Sep 2015 15:14:53 +0100
changeset 296841 8a6ece1de77aec317ed58430500c61e843c1f585
parent 296840 eddf5abd9a5fcdcf0b92230193d565397f502462
child 296842 396173a9720e87cf94ae8b23b075619a0440f1ed
push id5885
push usermleibovic@mozilla.com
push dateMon, 28 Sep 2015 18:19:32 +0000
reviewersmikedeboer
bugs1208466
milestone44.0a1
Bug 1208466 - Part 2. If an owner of a Loop link clicks their own link and join, make it open the conversation window. r=mikedeboer
browser/components/loop/content/shared/img/mozilla-logo.png
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/standalone/content/css/webapp.css
browser/components/loop/standalone/content/img/hello-logo-text.svg
browser/components/loop/standalone/content/img/mozilla-logo.svg
browser/components/loop/standalone/content/js/standaloneMetricsStore.js
browser/components/loop/standalone/content/js/standaloneRoomViews.js
browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/standalone/content/l10n/en-US/loop.properties
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/standalone/standaloneMetricsStore_test.js
browser/components/loop/test/standalone/standaloneRoomViews_test.js
browser/components/loop/test/standalone/webapp_test.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
deleted file mode 100644
index 672940c34745888fea43b9de7bd26de351c38049..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -464,31 +464,48 @@ loop.shared.actions = (function() {
       // roomInfoFailure: String - Optional.
       // roomName: String - Optional.
       // roomState: String - Optional.
       roomUrl: String
       // socialShareProviders: Array - Optional.
     }),
 
     /**
+     * Notifies if the user agent will handle the room or not.
+     */
+    UserAgentHandlesRoom: Action.define("userAgentHandlesRoom", {
+      handlesRoom: Boolean
+    }),
+
+    /**
      * Updates the Social API information when it is received.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     UpdateSocialShareInfo: Action.define("updateSocialShareInfo", {
       socialShareProviders: Array
     }),
 
     /**
      * Starts the process for the user to join the room.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     JoinRoom: Action.define("joinRoom", {
     }),
 
     /**
+     * A special action for metrics logging to define what type of join
+     * occurred when JoinRoom was activated.
+     * XXX: should move to some roomActions module - refs bug 1079284
+     */
+    MetricsLogJoinRoom: Action.define("metricsLogJoinRoom", {
+      userAgentHandledRoom: Boolean
+      // ownRoom: Boolean - Optional. Expected if firefoxHandledRoom is true.
+    }),
+
+    /**
      * Starts the process for the user to join the room.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     RetryAfterRoomFailure: Action.define("retryAfterRoomFailure", {
     }),
 
     /**
      * Signals the user has successfully joined the room on the loop-server.
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -127,16 +127,19 @@ loop.store.ActiveRoomStore = (function()
      */
     getInitialStoreState: function() {
       return {
         roomState: ROOM_STATES.INIT,
         audioMuted: false,
         videoMuted: false,
         remoteVideoEnabled: false,
         failureReason: undefined,
+        // Whether or not Firefox can handle this room in the conversation
+        // window, rather than us handling it in the standalone.
+        userAgentHandlesRoom: undefined,
         // Tracks if the room has been used during this
         // session. 'Used' means at least one call has been placed
         // with it. Entering and leaving the room without seeing
         // anyone is not considered as 'used'
         used: false,
         localVideoDimensions: {},
         remoteVideoDimensions: {},
         screenSharingState: SCREEN_SHARE_STATES.INACTIVE,
@@ -232,16 +235,17 @@ loop.store.ActiveRoomStore = (function()
       }
 
       this._registeredActions = true;
 
       this.dispatcher.register(this, [
         "roomFailure",
         "retryAfterRoomFailure",
         "updateRoomInfo",
+        "userAgentHandlesRoom",
         "gotMediaPermission",
         "joinRoom",
         "joinedRoom",
         "connectedToSdkServers",
         "connectionFailure",
         "setMute",
         "screenSharingState",
         "receivingScreenShare",
@@ -322,93 +326,172 @@ loop.store.ActiveRoomStore = (function()
 
     /**
      * Execute fetchServerData event action from the dispatcher. For rooms
      * we need to get the room context information from the server. We don't
      * need other data until the user decides to join the room.
      * This action is only used for the standalone UI.
      *
      * @param {sharedActions.FetchServerData} actionData
+     * @return {Promise} For testing purposes, returns a promise that is resolved
+     *                   once data is received from the server, and it is determined
+     *                   if Firefox handles the room or not.
      */
     fetchServerData: function(actionData) {
       if (actionData.windowType !== "room") {
         // Nothing for us to do here, leave it to other stores.
         return;
       }
 
       this.setStoreState({
         roomState: ROOM_STATES.GATHER,
         roomToken: actionData.token,
         standalone: true
       });
 
       this._registerPostSetupActions();
 
-      this._getRoomDataForStandalone(actionData.cryptoKey);
+      var dataPromise = this._getRoomDataForStandalone(actionData.cryptoKey);
+
+      var userAgentHandlesPromise = this._promiseDetectUserAgentHandles();
+
+      return Promise.all([dataPromise, userAgentHandlesPromise]).then(function(results) {
+        results.forEach(function(result) {
+          this.dispatcher.dispatch(result);
+        }.bind(this));
+      }.bind(this));
     },
 
+    /**
+     * Gets the room data for the standalone, decrypting it as necessary.
+     *
+     * @param  {String} roomCryptoKey The crypto key associated to the room.
+     * @return {Promise}              A promise that is resolved once the get
+     *                                and decryption is complete.
+     */
     _getRoomDataForStandalone: function(roomCryptoKey) {
-      this._mozLoop.rooms.get(this._storeState.roomToken, function(err, result) {
-        if (err) {
-          this.dispatchAction(new sharedActions.RoomFailure({
-            error: err,
-            failedJoinRequest: false
+      return new Promise(function(resolve, reject) {
+        this._mozLoop.rooms.get(this._storeState.roomToken, function(err, result) {
+          if (err) {
+            resolve(new sharedActions.RoomFailure({
+              error: err,
+              failedJoinRequest: false
+            }));
+            return;
+          }
+
+          var roomInfoData = new sharedActions.UpdateRoomInfo({
+            // If we've got this far, then we want to go to the ready state
+            // regardless of success of failure. This is because failures of
+            // crypto don't stop the user using the room, they just stop
+            // us putting up the information.
+            roomState: ROOM_STATES.READY,
+            roomUrl: result.roomUrl
+          });
+
+          if (!result.context && !result.roomName) {
+            roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.NO_DATA;
+            resolve(roomInfoData);
+            return;
+          }
+
+          // This handles 'legacy', non-encrypted room names.
+          if (result.roomName && !result.context) {
+            roomInfoData.roomName = result.roomName;
+            resolve(roomInfoData);
+            return;
+          }
+
+          if (!crypto.isSupported()) {
+            roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED;
+            resolve(roomInfoData);
+            return;
+          }
+
+          if (!roomCryptoKey) {
+            roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.NO_CRYPTO_KEY;
+            resolve(roomInfoData);
+            return;
+          }
+
+          crypto.decryptBytes(roomCryptoKey, result.context.value)
+                .then(function(decryptedResult) {
+            var realResult = JSON.parse(decryptedResult);
+
+            roomInfoData.roomDescription = realResult.description;
+            roomInfoData.roomContextUrls = realResult.urls;
+            roomInfoData.roomName = realResult.roomName;
+
+            resolve(roomInfoData);
+          }, function(error) {
+            roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.DECRYPT_FAILED;
+            resolve(roomInfoData);
+          });
+        }.bind(this));
+      }.bind(this));
+    },
+
+    /**
+     * If the user agent is Firefox, it sends a message to Firefox to see if
+     * the room can be handled within Firefox rather than the standalone UI.
+     *
+     * @return {Promise} A promise that is resolved once it has been determined
+     *                   if Firefox can handle the room.
+     */
+    _promiseDetectUserAgentHandles: function() {
+      return new Promise(function(resolve, reject) {
+        function resolveWithNotHandlingResponse() {
+          resolve(new sharedActions.UserAgentHandlesRoom({
+            handlesRoom: false
           }));
-          return;
         }
 
-        var roomInfoData = new sharedActions.UpdateRoomInfo({
-          // If we've got this far, then we want to go to the ready state
-          // regardless of success of failure. This is because failures of
-          // crypto don't stop the user using the room, they just stop
-          // us putting up the information.
-          roomState: ROOM_STATES.READY,
-          roomUrl: result.roomUrl
-        });
-
-        if (!result.context && !result.roomName) {
-          roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.NO_DATA;
-          this.dispatcher.dispatch(roomInfoData);
+        // If we're not Firefox, don't even try to see if it can be handled
+        // in the browser.
+        if (!loop.shared.utils.isFirefox(navigator.userAgent)) {
+          resolveWithNotHandlingResponse();
           return;
         }
 
-        // This handles 'legacy', non-encrypted room names.
-        if (result.roomName && !result.context) {
-          roomInfoData.roomName = result.roomName;
-          this.dispatcher.dispatch(roomInfoData);
-          return;
-        }
+        // Set up a timer in case older versions of Firefox don't give us a response.
+        var timer = setTimeout(resolveWithNotHandlingResponse, 250);
+        var webChannelListenerFunc;
+
+        // Listen for the result.
+        function webChannelListener(e) {
+          if (e.detail.id !== "loop-link-clicker") {
+            return;
+          }
 
-        if (!crypto.isSupported()) {
-          roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED;
-          this.dispatcher.dispatch(roomInfoData);
-          return;
+          // Stop the default response.
+          clearTimeout(timer);
+
+          // Remove the listener.
+          window.removeEventListener("WebChannelMessageToContent", webChannelListenerFunc);
+
+          // Resolve with the details of if we're able to handle or not.
+          resolve(new sharedActions.UserAgentHandlesRoom({
+            handlesRoom: !!e.detail.message && e.detail.message.response
+          }));
         }
 
-        if (!roomCryptoKey) {
-          roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.NO_CRYPTO_KEY;
-          this.dispatcher.dispatch(roomInfoData);
-          return;
-        }
+        webChannelListenerFunc = webChannelListener.bind(this);
 
-        var dispatcher = this.dispatcher;
+        window.addEventListener("WebChannelMessageToContent", webChannelListenerFunc);
 
-        crypto.decryptBytes(roomCryptoKey, result.context.value)
-              .then(function(decryptedResult) {
-          var realResult = JSON.parse(decryptedResult);
-
-          roomInfoData.roomDescription = realResult.description;
-          roomInfoData.roomContextUrls = realResult.urls;
-          roomInfoData.roomName = realResult.roomName;
-
-          dispatcher.dispatch(roomInfoData);
-        }, function(error) {
-          roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.DECRYPT_FAILED;
-          dispatcher.dispatch(roomInfoData);
-        });
+        // Now send a message to the chrome to see if it can handle this room.
+        window.dispatchEvent(new window.CustomEvent("WebChannelMessageToChrome", {
+          detail: {
+            id: "loop-link-clicker",
+            message: {
+              command: "checkWillOpenRoom",
+              roomToken: this._storeState.roomToken
+            }
+          }
+        }));
       }.bind(this));
     },
 
     /**
      * Handles the updateRoomInfo action. Updates the room data.
      *
      * @param {sharedActions.UpdateRoomInfo} actionData
      */
@@ -422,16 +505,28 @@ loop.store.ActiveRoomStore = (function()
         if (actionData[field] !== undefined) {
           newState[OPTIONAL_ROOMINFO_FIELDS[field]] = actionData[field];
         }
       });
       this.setStoreState(newState);
     },
 
     /**
+     * Handles the userAgentHandlesRoom action. Updates the store's data with
+     * the new state.
+     *
+     * @param {sharedActions.userAgentHandlesRoom} actionData
+     */
+    userAgentHandlesRoom: function(actionData) {
+      this.setStoreState({
+        userAgentHandlesRoom: actionData.handlesRoom
+      });
+    },
+
+    /**
      * Handles the updateSocialShareInfo action. Updates the room data with new
      * Social API info.
      *
      * @param  {sharedActions.UpdateSocialShareInfo} actionData
      */
     updateSocialShareInfo: function(actionData) {
       this.setStoreState({
         socialShareProviders: actionData.socialShareProviders
@@ -472,24 +567,21 @@ loop.store.ActiveRoomStore = (function()
      */
     _handleSocialShareUpdate: function() {
       this.dispatchAction(new sharedActions.UpdateSocialShareInfo({
         socialShareProviders: this._mozLoop.getSocialShareProviders()
       }));
     },
 
     /**
-     * Handles the action to join to a room.
+     * Checks that there are audio and video devices available, and joins the
+     * room if there are. If there aren't then it will dispatch a ConnectionFailure
+     * action with NO_MEDIA.
      */
-    joinRoom: function() {
-      // Reset the failure reason if necessary.
-      if (this.getStoreState().failureReason) {
-        this.setStoreState({failureReason: undefined});
-      }
-
+    _checkDevicesAndJoinRoom: function() {
       // XXX Ideally we'd do this check before joining a room, but we're waiting
       // for the UX for that. See bug 1166824. In the meantime this gives us
       // additional information for analysis.
       loop.shared.utils.hasAudioOrVideoDevices(function(hasDevices) {
         if (hasDevices) {
           // MEDIA_WAIT causes the views to dispatch sharedActions.SetupStreamElements,
           // which in turn starts the sdk obtaining the device permission.
           this.setStoreState({roomState: ROOM_STATES.MEDIA_WAIT});
@@ -497,16 +589,87 @@ loop.store.ActiveRoomStore = (function()
           this.dispatchAction(new sharedActions.ConnectionFailure({
             reason: FAILURE_DETAILS.NO_MEDIA
           }));
         }
       }.bind(this));
     },
 
     /**
+     * Hands off the room join to Firefox.
+     */
+    _handoffRoomJoin: function() {
+      var channelListener;
+
+      function handleRoomJoinResponse(e) {
+        if (e.detail.id !== "loop-link-clicker") {
+          return;
+        }
+
+        window.removeEventListener("WebChannelMessageToContent", channelListener);
+
+        if (!e.detail.message || !e.detail.message.response) {
+          // XXX Firefox didn't handle this, even though it said it could
+          // previously. We should add better user feedback here.
+          console.error("Firefox didn't handle room it said it could.");
+        } else {
+          this.dispatcher.dispatch(new sharedActions.JoinedRoom({
+            apiKey: "",
+            sessionToken: "",
+            sessionId: "",
+            expires: 0
+          }));
+        }
+      }
+
+      channelListener = handleRoomJoinResponse.bind(this);
+
+      window.addEventListener("WebChannelMessageToContent", channelListener);
+
+      // Now we're set up, dispatch an event.
+      window.dispatchEvent(new window.CustomEvent("WebChannelMessageToChrome", {
+        detail: {
+          id: "loop-link-clicker",
+          message: {
+            command: "openRoom",
+            roomToken: this._storeState.roomToken
+          }
+        }
+      }));
+    },
+
+    /**
+     * Handles the action to join to a room.
+     */
+    joinRoom: function() {
+      // Reset the failure reason if necessary.
+      if (this.getStoreState().failureReason) {
+        this.setStoreState({ failureReason: undefined });
+      }
+
+      // If we're standalone and we know Firefox can handle the room, then hand
+      // it off.
+      if (this._storeState.standalone && this._storeState.userAgentHandlesRoom) {
+        this.dispatcher.dispatch(new sharedActions.MetricsLogJoinRoom({
+          userAgentHandledRoom: true,
+          ownRoom: true
+        }));
+        this._handoffRoomJoin();
+        return;
+      }
+
+      this.dispatcher.dispatch(new sharedActions.MetricsLogJoinRoom({
+        userAgentHandledRoom: false
+      }));
+
+      // Otherwise, we handle the room ourselves.
+      this._checkDevicesAndJoinRoom();
+    },
+
+    /**
      * Handles the action that signifies when media permission has been
      * granted and starts joining the room.
      */
     gotMediaPermission: function() {
       this.setStoreState({roomState: ROOM_STATES.JOINING});
 
       this._mozLoop.rooms.join(this._storeState.roomToken,
         function(error, responseData) {
@@ -535,16 +698,25 @@ loop.store.ActiveRoomStore = (function()
     /**
      * Handles the data received from joining a room. It stores the relevant
      * data, and sets up the refresh timeout for ensuring membership of the room
      * is refreshed regularly.
      *
      * @param {sharedActions.JoinedRoom} actionData
      */
     joinedRoom: function(actionData) {
+      // If we're standalone and firefox is handling, then just store the new
+      // state. No need to do anything else.
+      if (this._storeState.standalone && this._storeState.userAgentHandlesRoom) {
+        this.setStoreState({
+          roomState: ROOM_STATES.JOINED
+        });
+        return;
+      }
+
       this.setStoreState({
         apiKey: actionData.apiKey,
         sessionToken: actionData.sessionToken,
         sessionId: actionData.sessionId,
         roomState: ROOM_STATES.JOINED
       });
 
       this._setRefreshTimeout(actionData.expires);
--- a/browser/components/loop/standalone/content/css/webapp.css
+++ b/browser/components/loop/standalone/content/css/webapp.css
@@ -20,16 +20,37 @@ body,
 
 /**
  * Note: the is-standalone-room class is dynamically set by the StandaloneRoomView.
  */
 .standalone.is-standalone-room {
   background: #000;
 }
 
+/* Logos */
+
+.loop-logo-text {
+  background: url("../img/hello-logo-text.svg") no-repeat;
+  width: 200px;
+  height: 36px;
+}
+
+.loop-logo {
+  background: url("../shared/img/helloicon.svg") no-repeat;
+  width: 100px;
+  height: 100px;
+}
+
+.mozilla-logo {
+  background: url("../img/mozilla-logo.svg#logo") no-repeat;
+  background-size: contain;
+  width: 100px;
+  height: 30px;
+}
+
 .room-conversation-wrapper > .beta-logo {
   position: fixed;
   top: 0;
   left: 0;
   width: 50px;
   height: 50px;
   background: transparent url(../shared/img/beta-ribbon.svg) no-repeat;
   background-size: 50px;
@@ -38,17 +59,17 @@ body,
 
 /* Footer */
 
 .footer-logo {
   width: 100px;
   margin: 0 auto;
   height: 30px;
   background-size: contain;
-  background-image: url("../shared/img/mozilla-logo.png");
+  background-image: url("../img/mozilla-logo.svg#logo-white");
   background-repeat: no-repeat;
 }
 
 .rooms-footer {
   background: #000;
   margin: 0 10px;
   text-align: left;
   height: 3em;
@@ -133,16 +154,54 @@ html[dir="rtl"] .rooms-footer .footer-lo
   font-weight: 300;
 }
 
 .btn-unsupported-device {
   width: 80%;
   line-height: 24px;
 }
 
+/**
+ * Handle in Firefox views
+ */
+
+.handle-user-agent-view-scroller {
+  height: 100%;
+  overflow: scroll;
+}
+
+.handle-user-agent-view {
+  margin: 2rem auto;
+  width: 500px;
+}
+
+.handle-user-agent-view > .info-panel {
+  padding-bottom: 40px;
+  font-size: 1.6rem;
+}
+
+.handle-user-agent-view > p,
+.handle-user-agent-view > .info-panel > p {
+  margin-top: 0;
+  margin: 2rem auto;
+}
+
+.handle-user-agent-view > .info-panel > button {
+  width: 80%;
+  height: 4rem;
+  font-size: 1.6rem;
+  font-weight: bold;
+}
+
+.handle-user-agent-view > .info-panel > button.disabled {
+  background-color: #EBEBEB;
+  border-color: #EBEBEB;
+  color: #B2B0B3;
+}
+
 /* Room wrapper layout */
 
 .room-conversation-wrapper {
   position: relative;
   height: 100%;
   background: #000;
 }
 
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/standalone/content/img/hello-logo-text.svg
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 467.5 76"><path fill="#5D5F64" d="M263.4 28.2c-.2.4-.5.8-.8 1.2-.3.3-.7.6-1.2.8-.5.2-.9.3-1.4.3-.5 0-1-.1-1.4-.3-.5-.2-.8-.4-1.2-.8-.3-.3-.6-.7-.8-1.2-.2-.4-.3-.9-.3-1.4 0-.5.1-1 .3-1.4.2-.4.4-.8.8-1.2.3-.3.7-.6 1.2-.8.4-.2.9-.3 1.4-.3.5 0 1 .1 1.4.3.4.2.8.4 1.2.8.3.3.6.7.8 1.2.2.5.3.9.3 1.4 0 .5-.1 1-.3 1.4zm-.7-2.6c-.2-.4-.4-.7-.6-1-.2-.3-.6-.5-.9-.7-.4-.2-.7-.2-1.1-.2-.4 0-.8.1-1.1.2-.4.2-.7.4-.9.7-.3.3-.5.6-.6 1-.2.4-.2.8-.2 1.2 0 .4.1.8.2 1.2.2.4.4.7.6 1 .3.3.6.5.9.7.3.2.7.2 1.1.2.4 0 .8-.1 1.1-.2.4-.2.7-.4.9-.7.3-.3.5-.6.6-1 .1-.4.2-.8.2-1.2.1-.4 0-.8-.2-1.2zm-2.2 2.6c-.1-.3-.3-.5-.4-.6-.1-.1-.2-.3-.3-.4l-.1-.1h-.2v1.8h-.7v-4.1h1.3c.2 0 .4 0 .6.1.2.1.3.1.4.2.1.1.2.2.2.4 0 .1.1.3.1.5 0 .3-.1.6-.3.8-.2.2-.4.3-.8.3l.1.1.2.2c.1.1.1.2.2.2.1.1.1.2.2.3l.6 1h-.8l-.3-.7zm0-2.8c-.1-.1-.3-.2-.6-.2h-.4v1.3h.8c.1 0 .2-.1.2-.1.1-.1.2-.3.2-.5s0-.3-.2-.5zM35.6 13.9h-24v19h19.5v9.4H11.6v32.1H0V4.3h37.1l-1.5 9.6zM41.7 8.2c0-4.1 3.2-7.5 7.4-7.5 3.9 0 7.3 3.2 7.3 7.5 0 4.1-3.3 7.4-7.5 7.4-4.1 0-7.2-3.4-7.2-7.4zm1.6 66.2V24l11.2-2v52.4H43.3zM89.2 32.9c-1.1-.4-1.9-.7-3.1-.7-4.7 0-8.6 3.4-9.6 7.6v34.6H65.3V38.3c0-6.5-.7-10.6-1.8-13.8l10.2-2.6c1.2 2.3 1.9 5.3 1.9 8.1 4-5.6 8.1-8.2 13.1-8.2 1.6 0 2.6.2 3.9.8l-3.4 10.3zM103.3 51.8v.8c0 7.1 2.6 14.6 12.7 14.6 4.8 0 8.9-1.7 12.7-5.1l4.4 6.8c-5.4 4.6-11.5 6.8-18.4 6.8-14.6 0-23.7-10.4-23.7-26.8 0-9 1.9-15 6.4-20 4.2-4.8 9.2-6.9 15.7-6.9 5.1 0 9.7 1.3 14.1 5.3 4.5 4 6.7 10.3 6.7 22.3v2.3h-30.6zm9.8-21.4c-6.3 0-9.7 5-9.7 13.3h18.9c0-8.4-3.6-13.3-9.2-13.3zM165.5 10c-2.5-1.2-4-1.8-6.2-1.8-3.8 0-6.3 2.6-6.3 7.2v7.8h13.4l-2.8 7.7h-10.4v43.5h-11V30.9h-4.8v-7.7h5s-.3-2.8-.3-7.6C142.1 5 148.5 0 157.6 0c4.4 0 8 .9 11.5 2.9l-3.6 7.1zM210.1 49c0 16.5-8.8 26.7-22.7 26.7-13.9 0-22.6-10.4-22.6-26.8S173.6 22 187.2 22c14.6 0 22.9 10.8 22.9 27zm-32.9-.8c0 14.9 3.7 19.2 10.4 19.2 6.6 0 10.2-5.4 10.2-18.2 0-14.5-4-18.8-10.5-18.8-7 0-10.1 5.3-10.1 17.8z"/><path fill="#5D5F64" d="M243.6 74.4c-1.8-2.9-10.1-17.3-11.1-19.1-1.9 3.8-9.2 16.3-11.1 19.1h-14.1l19-27.8-14.8-22 12-2.4c2.3 3.8 6.9 11.8 9.3 16.6 1.4-3.3 6.6-13.6 8.1-15.7h13l-15.1 23.3 18.7 27.9h-13.9z"/><g fill="#5D5F64"><path d="M280.6 4.5h8.2v29.3h29.5V4.5h8.4v70.1h-8.4V40.7h-29.5v33.9h-8.2V4.5zM371.5 64.3l3.1 5.1c-4.5 4.1-10.6 6.3-17.2 6.3-14.1 0-22.6-10.2-22.6-27.1 0-8.6 1.8-14.1 6.1-19.2 4.1-4.8 9.1-7.1 15.2-7.1 5.5 0 10.3 1.9 13.8 5.5 4.4 4.5 5.6 9.3 5.8 21.5v1.1H344v1.2c0 4.8.6 8.5 2.3 11.1 2.9 4.4 7.6 6.2 12.7 6.2 4.9.2 8.9-1.3 12.5-4.6zM344 44.5h23.3c-.1-5.5-.8-8.9-2.3-11.3-1.7-2.8-5.3-4.5-9.2-4.5-7.3-.1-11.4 5.2-11.8 15.8zM393.5 64.4c0 4 .6 5.1 2.9 5.1.3 0 1-.2 1-.2l1.6 5.2c-2 .9-3 1.1-5.1 1.1-2.5 0-4.5-.7-6-2.1-1.6-1.4-2.5-3.6-2.5-7.3v-54c0-6.6-1.2-10.4-1.2-10.4l8-1.5s1.3 4.3 1.3 12.1v52zM413.9 64.4c0 4 .6 5.1 2.9 5.1.3 0 1-.2 1-.2l1.6 5.2c-2 .9-3 1.1-5.1 1.1-2.5 0-4.5-.7-6-2.1-1.6-1.4-2.5-3.6-2.5-7.3v-54c0-6.6-1.2-10.4-1.2-10.4l8-1.5s1.3 4.3 1.3 12.1v52zM445.3 22.2c8.5 0 14 3.9 17.5 8.9 3.2 4.6 4.7 10.6 4.7 18.9 0 17-9.1 26-21.9 26-14 0-22-10.3-22-27.1 0-16.6 8.3-26.7 21.7-26.7zm-.1 6.5c-4.5 0-8.7 2.1-10.4 5.5-1.6 3.2-2.5 7.3-2.5 13.3 0 7.2 1.2 13.5 3.2 16.7 1.8 3.1 5.9 5.1 10.3 5.1 5.3 0 9.3-2.8 11-7.7 1.1-3.2 1.5-6 1.5-11 0-7.2-.7-12-2.4-15.3-2-4.5-6.5-6.6-10.7-6.6z"/></g></svg>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/standalone/content/img/mozilla-logo.svg
@@ -0,0 +1,1 @@
+<svg width="568" height="148" viewBox="0 0 568 148" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>Fill 1 Copy</title><style>use:not(:target) { display: none; } use { fill: #383838; } use[id$=&quot;-white&quot;] { fill: #fff; }</style><defs><path id="mozilla-logo" d="M23.39 42.294c1.72 2.656 2.478 5.026 3.44 10.024 6.728-6.568 15.025-10.024 24.08-10.024 8.18 0 14.89 2.656 20.085 8.077 1.4 1.36 2.75 3.113 3.913 4.833 9.038-9.257 17.132-12.91 27.976-12.91 7.722 0 15.042 2.31 19.51 6.156 5.583 4.823 7.353 10.622 7.353 24.124v70.244h-25.092V77.606c0-11.82-1.4-14.107-8.13-14.107-4.822 0-11.6 3.29-17.166 8.305v71.013H54.872V78.533c0-12.326-1.787-15.22-8.97-15.22-4.773 0-11.384 2.472-16.95 7.514v71.99H3.694V73.913c0-14.266-.978-20.43-3.693-25.277l23.39-6.34zm152.244 28.92c-1.772 5.228-2.715 12.168-2.715 22.016 0 11.357 1.162 19.89 3.27 24.89 2.327 5.406 8.146 8.104 13.12 8.104 11.197 0 15.986-10.025 15.986-33.372 0-13.324-1.737-22.025-5.193-26.476-2.48-3.255-6.51-5.194-11.164-5.194-6.19 0-11.216 3.844-13.306 10.032zm46.49-14.265c7.893 9.257 11.418 20.057 11.418 36.07 0 16.982-3.895 28.582-12.412 38.212-7.486 8.483-17.352 13.71-32.563 13.71-26.863 0-44.384-20.076-44.384-51.13 0-31.08 17.706-51.738 44.384-51.738 14.08 0 25.077 4.84 33.558 14.875zm94.588-12.935V61.77l-43.64 63.096h45.344l-6.19 17.952h-72.713v-16.012l46.46-64.645H243.39V44.016h73.322zm40.694-2.328v101.13h-25.853V45.75l25.853-4.063zm3.035-24.874c0 8.89-7.066 15.987-16.002 15.987-8.652 0-15.767-7.098-15.767-15.987 0-8.87 7.353-16.03 16.204-16.03 8.67 0 15.567 7.16 15.567 16.03zm44.048 8.89v76.98c0 17.006.203 19.292 1.755 21.99.977 1.753 3.068 2.698 5.227 2.698.927 0 1.483 0 2.883-.354l4.42 15.42c-4.42 1.73-9.832 2.693-15.43 2.693-11.03 0-19.9-5.197-22.97-13.477-1.94-5.024-2.36-8.127-2.36-22.208V35.7c0-12.918-.337-20.81-1.3-29.722L403.157 0c.926 5.397 1.33 11.77 1.33 25.702zm53.625 0v76.98c0 17.006.22 19.292 1.806 21.99.91 1.753 3 2.698 5.16 2.698.977 0 1.567 0 2.933-.354l4.402 15.42c-4.402 1.73-9.816 2.693-15.432 2.693-11.01 0-19.897-5.197-22.983-13.477-1.973-5.024-2.294-8.127-2.294-22.208V35.7c0-12.918-.387-20.81-1.383-29.722L456.73 0c1.064 5.397 1.383 11.77 1.383 25.702zm74.082 73.894c-17.875 0-24.148 3.254-24.148 15.076 0 7.688 4.89 12.9 11.45 12.9 4.806 0 9.664-2.513 13.492-6.746l.42-21.23h-1.215zM498.687 47.69c9.613-4.064 17.878-5.785 26.983-5.785 16.628 0 27.993 6.157 31.888 17.167 1.282 4.05 1.872 7.135 1.755 17.757L558.687 110v1.752c0 10.607 1.755 14.672 9.313 20.255l-13.73 15.85c-6.038-2.53-11.416-6.98-13.93-11.982-1.905 1.948-4.047 3.837-6.003 5.202-4.79 3.476-11.787 5.414-19.883 5.414-22.005 0-33.96-11.215-33.96-30.86 0-23.196 16.053-34.015 47.485-34.015 1.888 0 3.676 0 5.818.22v-4.03c0-11.02-2.142-14.69-11.653-14.69-8.196 0-17.926 4.03-28.517 11.19l-11.012-18.533c5.245-3.29 9.108-5.203 16.07-8.087z"/></defs><use id="logo" xlink:href="#mozilla-logo"/><use id="logo-white" xlink:href="#mozilla-logo"/></svg>
\ No newline at end of file
--- a/browser/components/loop/standalone/content/js/standaloneMetricsStore.js
+++ b/browser/components/loop/standalone/content/js/standaloneMetricsStore.js
@@ -39,17 +39,17 @@ loop.store.StandaloneMetricsStore = (fun
     support: "support link click"
   };
 
   var StandaloneMetricsStore = loop.store.createStore({
     actions: [
       "connectedToSdkServers",
       "connectionFailure",
       "gotMediaPermission",
-      "joinRoom",
+      "metricsLogJoinRoom",
       "joinedRoom",
       "leaveRoom",
       "mediaConnected",
       "recordClick",
       "remotePeerConnected",
       "retryAfterRoomFailure",
       "tileShown",
       "windowUnload"
@@ -139,20 +139,30 @@ loop.store.StandaloneMetricsStore = (fun
      */
     gotMediaPermission: function() {
       this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.success,
         "Media granted");
     },
 
     /**
      * Handles the user clicking the join room button.
+     *
+     * @param {sharedActions.MetricsLogJoinRoom} actionData
      */
-    joinRoom: function() {
-      this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
-        "Join the conversation");
+    metricsLogJoinRoom: function(actionData) {
+      var label;
+
+      if (actionData.userAgentHandledRoom) {
+        label = actionData.ownRoom ? "Joined own room in Firefox" :
+          "Joined in Firefox";
+      } else {
+        label = "Join the conversation";
+      }
+
+      this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button, label);
     },
 
     /**
      * Handles the room having been joined on the loop-server.
      */
     joinedRoom: function() {
       this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.success,
         "Joined room");
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -53,16 +53,69 @@ loop.standaloneRoomViews = (function(moz
         React.createElement("p", {
           className: "terms-service", 
           dangerouslySetInnerHTML: {__html: this._getContent()}, 
           onClick: this.recordClick})
       );
     }
   });
 
+  var StandaloneHandleUserAgentView = React.createClass({displayName: "StandaloneHandleUserAgentView",
+    mixins: [
+      loop.store.StoreMixin("activeRoomStore")
+    ],
+
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+    },
+
+    getInitialState: function() {
+      return this.getStoreState();
+    },
+
+    handleJoinButton: function() {
+      this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
+    },
+
+    render: function() {
+      var buttonMessage = this.state.roomState === ROOM_STATES.JOINED ?
+        mozL10n.get("rooms_room_joined_own_conversation_label") :
+        mozL10n.get("rooms_room_join_label");
+
+      var buttonClasses = React.addons.classSet({
+        btn: true,
+        "btn-info": true,
+        disabled: this.state.roomState === ROOM_STATES.JOINED
+      });
+
+      // The extra scroller div here is for providing a scroll view for shorter
+      // screens, as the common.css specifies overflow:hidden for the body which
+      // we need in some places.
+      return (
+        React.createElement("div", {className: "handle-user-agent-view-scroller"}, 
+          React.createElement("div", {className: "handle-user-agent-view"}, 
+            React.createElement("div", {className: "info-panel"}, 
+              React.createElement("p", {className: "loop-logo-text", title:  mozL10n.get("clientShortname2") }), 
+              React.createElement("p", {className: "roomName"},  this.state.roomName), 
+              React.createElement("p", {className: "loop-logo"}), 
+              React.createElement("button", {
+                className: buttonClasses, 
+                onClick: this.handleJoinButton}, 
+                buttonMessage
+              )
+            ), 
+            React.createElement(ToSView, {
+              dispatcher: this.props.dispatcher}), 
+            React.createElement("p", {className: "mozilla-logo"})
+          )
+        )
+      );
+    }
+  });
+
   /**
    * Handles display of failures, determining the correct messages and
    * displaying the retry button at appropriate times.
    */
   var StandaloneRoomFailureView = React.createClass({displayName: "StandaloneRoomFailureView",
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // One of FAILURE_DETAILS.
@@ -606,17 +659,55 @@ loop.standaloneRoomViews = (function(moz
                       visible: this._roomIsActive()}})
           ), 
           React.createElement(StandaloneRoomFooter, {dispatcher: this.props.dispatcher})
         )
       );
     }
   });
 
+  var StandaloneRoomControllerView = React.createClass({displayName: "StandaloneRoomControllerView",
+    mixins: [
+      loop.store.StoreMixin("activeRoomStore")
+    ],
+
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      isFirefox: React.PropTypes.bool.isRequired
+    },
+
+    getInitialState: function() {
+      return this.getStoreState();
+    },
+
+    render: function() {
+      // If we don't know yet, don't display anything.
+      if (this.state.firefoxHandlesRoom === undefined) {
+        return null;
+      }
+
+      if (this.state.firefoxHandlesRoom) {
+        return (
+          React.createElement(StandaloneHandleUserAgentView, {
+            dispatcher: this.props.dispatcher})
+        );
+      }
+
+      return (
+        React.createElement(StandaloneRoomView, {
+          activeRoomStore: this.getStore(), 
+          dispatcher: this.props.dispatcher, 
+          isFirefox: this.props.isFirefox})
+      );
+    }
+  });
+
   return {
+    StandaloneHandleUserAgentView: StandaloneHandleUserAgentView,
+    StandaloneRoomControllerView: StandaloneRoomControllerView,
     StandaloneRoomFailureView: StandaloneRoomFailureView,
     StandaloneRoomFooter: StandaloneRoomFooter,
     StandaloneRoomHeader: StandaloneRoomHeader,
     StandaloneRoomInfoArea: StandaloneRoomInfoArea,
     StandaloneRoomView: StandaloneRoomView,
     ToSView: ToSView
   };
 })(navigator.mozL10n);
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -53,16 +53,69 @@ loop.standaloneRoomViews = (function(moz
         <p
           className="terms-service"
           dangerouslySetInnerHTML={{__html: this._getContent()}}
           onClick={this.recordClick}></p>
       );
     }
   });
 
+  var StandaloneHandleUserAgentView = React.createClass({
+    mixins: [
+      loop.store.StoreMixin("activeRoomStore")
+    ],
+
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+    },
+
+    getInitialState: function() {
+      return this.getStoreState();
+    },
+
+    handleJoinButton: function() {
+      this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
+    },
+
+    render: function() {
+      var buttonMessage = this.state.roomState === ROOM_STATES.JOINED ?
+        mozL10n.get("rooms_room_joined_own_conversation_label") :
+        mozL10n.get("rooms_room_join_label");
+
+      var buttonClasses = React.addons.classSet({
+        btn: true,
+        "btn-info": true,
+        disabled: this.state.roomState === ROOM_STATES.JOINED
+      });
+
+      // The extra scroller div here is for providing a scroll view for shorter
+      // screens, as the common.css specifies overflow:hidden for the body which
+      // we need in some places.
+      return (
+        <div className="handle-user-agent-view-scroller">
+          <div className="handle-user-agent-view">
+            <div className="info-panel">
+              <p className="loop-logo-text" title={ mozL10n.get("clientShortname2") }></p>
+              <p className="roomName">{ this.state.roomName }</p>
+              <p className="loop-logo" />
+              <button
+                className={buttonClasses}
+                onClick={this.handleJoinButton}>
+                {buttonMessage}
+              </button>
+            </div>
+            <ToSView
+              dispatcher={this.props.dispatcher} />
+            <p className="mozilla-logo" />
+          </div>
+        </div>
+      );
+    }
+  });
+
   /**
    * Handles display of failures, determining the correct messages and
    * displaying the retry button at appropriate times.
    */
   var StandaloneRoomFailureView = React.createClass({
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // One of FAILURE_DETAILS.
@@ -606,17 +659,55 @@ loop.standaloneRoomViews = (function(moz
                       visible: this._roomIsActive()}} />
           </sharedViews.MediaLayoutView>
           <StandaloneRoomFooter dispatcher={this.props.dispatcher} />
         </div>
       );
     }
   });
 
+  var StandaloneRoomControllerView = React.createClass({
+    mixins: [
+      loop.store.StoreMixin("activeRoomStore")
+    ],
+
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      isFirefox: React.PropTypes.bool.isRequired
+    },
+
+    getInitialState: function() {
+      return this.getStoreState();
+    },
+
+    render: function() {
+      // If we don't know yet, don't display anything.
+      if (this.state.firefoxHandlesRoom === undefined) {
+        return null;
+      }
+
+      if (this.state.firefoxHandlesRoom) {
+        return (
+          <StandaloneHandleUserAgentView
+            dispatcher={this.props.dispatcher} />
+        );
+      }
+
+      return (
+        <StandaloneRoomView
+          activeRoomStore={this.getStore()}
+          dispatcher={this.props.dispatcher}
+          isFirefox={this.props.isFirefox} />
+      );
+    }
+  });
+
   return {
+    StandaloneHandleUserAgentView: StandaloneHandleUserAgentView,
+    StandaloneRoomControllerView: StandaloneRoomControllerView,
     StandaloneRoomFailureView: StandaloneRoomFailureView,
     StandaloneRoomFooter: StandaloneRoomFooter,
     StandaloneRoomHeader: StandaloneRoomHeader,
     StandaloneRoomInfoArea: StandaloneRoomInfoArea,
     StandaloneRoomView: StandaloneRoomView,
     ToSView: ToSView
   };
 })(navigator.mozL10n);
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -148,17 +148,17 @@ loop.webapp = (function(_, OT, mozL10n) 
         case "unsupportedDevice": {
           return React.createElement(UnsupportedDeviceView, {platform: this.state.unsupportedPlatform});
         }
         case "unsupportedBrowser": {
           return React.createElement(UnsupportedBrowserView, {isFirefox: this.state.isFirefox});
         }
         case "room": {
           return (
-            React.createElement(loop.standaloneRoomViews.StandaloneRoomView, {
+            React.createElement(loop.standaloneRoomViews.StandaloneRoomControllerView, {
               activeRoomStore: this.props.activeRoomStore, 
               dispatcher: this.props.dispatcher, 
               isFirefox: this.state.isFirefox})
           );
         }
         case "home": {
           return React.createElement(HomeView, null);
         }
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -148,17 +148,17 @@ loop.webapp = (function(_, OT, mozL10n) 
         case "unsupportedDevice": {
           return <UnsupportedDeviceView platform={this.state.unsupportedPlatform}/>;
         }
         case "unsupportedBrowser": {
           return <UnsupportedBrowserView isFirefox={this.state.isFirefox}/>;
         }
         case "room": {
           return (
-            <loop.standaloneRoomViews.StandaloneRoomView
+            <loop.standaloneRoomViews.StandaloneRoomControllerView
               activeRoomStore={this.props.activeRoomStore}
               dispatcher={this.props.dispatcher}
               isFirefox={this.state.isFirefox} />
           );
         }
         case "home": {
           return <HomeView />;
         }
--- a/browser/components/loop/standalone/content/l10n/en-US/loop.properties
+++ b/browser/components/loop/standalone/content/l10n/en-US/loop.properties
@@ -63,16 +63,17 @@ rooms_list_deleteConfirmation_label=Are 
 rooms_new_room_button_label=Start a conversation
 rooms_only_occupant_label2=You're the only one here.
 rooms_panel_title=Choose a conversation or start a new one
 rooms_room_full_label=There are already two people in this conversation.
 rooms_room_full_call_to_action_nonFx_label=Download {{brandShortname}} to start your own
 rooms_room_full_call_to_action_label=Learn more about {{clientShortname}} ยป
 rooms_room_joined_label=Someone has joined the conversation!
 rooms_room_join_label=Join the conversation
+rooms_room_joined_own_conversation_label=Enjoy your conversation
 rooms_display_name_guest=Guest
 rooms_unavailable_notification_message=Sorry, you cannot join this conversation. The link may be expired or invalid.
 rooms_media_denied_message=We could not get access to your microphone or camera. Please reload the page to try again.
 room_information_failure_not_available=No information about this conversation is available. Please request a new link from the person who sent it to you.
 room_information_failure_unsupported_browser=Your browser cannot access any information about this conversation. Please make sure you're using the latest version.
 
 ## LOCALIZATION_NOTE(rooms_read_while_wait_offer): This string is followed by a
 # tile/offer image and title that are provided by a separate service that has
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -1,16 +1,17 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 describe("loop.store.ActiveRoomStore", function () {
   "use strict";
 
   var expect = chai.expect;
   var sharedActions = loop.shared.actions;
+  var sharedUtils = loop.shared.utils;
   var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
   var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
   var sandbox, dispatcher, store, fakeMozLoop, fakeSdkDriver, fakeMultiplexGum;
   var standaloneMediaRestore;
 
@@ -429,48 +430,48 @@ describe("loop.store.ActiveRoomStore", f
     });
 
     it("should call mozLoop.rooms.get to get the room data", function() {
       store.fetchServerData(fetchServerAction);
 
       sinon.assert.calledOnce(fakeMozLoop.rooms.get);
     });
 
-    it("should dispatch an UpdateRoomInfo message with 'no data' failure if neither roomName nor context are supplied", function() {
+    it("should dispatch an UpdateRoomInfo message with failure if neither roomName nor context are supplied", function() {
       fakeMozLoop.rooms.get.callsArgWith(1, null, {
         roomUrl: "http://invalid"
       });
 
-      store.fetchServerData(fetchServerAction);
-
-      sinon.assert.calledOnce(dispatcher.dispatch);
-      sinon.assert.calledWithExactly(dispatcher.dispatch,
-        new sharedActions.UpdateRoomInfo({
-          roomInfoFailure: ROOM_INFO_FAILURES.NO_DATA,
-          roomState: ROOM_STATES.READY,
-          roomUrl: "http://invalid"
-        }));
+      return store.fetchServerData(fetchServerAction).then(function() {
+        sinon.assert.called(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.UpdateRoomInfo({
+            roomInfoFailure: ROOM_INFO_FAILURES.NO_DATA,
+            roomState: ROOM_STATES.READY,
+            roomUrl: "http://invalid"
+          }));
+      });
     });
 
     describe("mozLoop.rooms.get returns roomName as a separate field (no context)", function() {
       it("should dispatch UpdateRoomInfo if mozLoop.rooms.get is successful", function() {
         var roomDetails = {
           roomName: "fakeName",
           roomUrl: "http://invalid"
         };
 
         fakeMozLoop.rooms.get.callsArgWith(1, null, roomDetails);
 
-        store.fetchServerData(fetchServerAction);
-
-        sinon.assert.calledOnce(dispatcher.dispatch);
-        sinon.assert.calledWithExactly(dispatcher.dispatch,
-          new sharedActions.UpdateRoomInfo(_.extend({
-            roomState: ROOM_STATES.READY
-          }, roomDetails)));
+        return store.fetchServerData(fetchServerAction).then(function() {
+          sinon.assert.called(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UpdateRoomInfo(_.extend({
+              roomState: ROOM_STATES.READY
+            }, roomDetails)));
+        });
       });
     });
 
     describe("mozLoop.rooms.get returns encryptedContext", function() {
       var roomDetails, expectedDetails;
 
       beforeEach(function() {
         roomDetails = {
@@ -486,58 +487,58 @@ describe("loop.store.ActiveRoomStore", f
         fakeMozLoop.rooms.get.callsArgWith(1, null, roomDetails);
 
         sandbox.stub(loop.crypto, "isSupported").returns(true);
       });
 
       it("should dispatch UpdateRoomInfo message with 'unsupported' failure if WebCrypto is unsupported", function() {
         loop.crypto.isSupported.returns(false);
 
-        store.fetchServerData(fetchServerAction);
-
-        sinon.assert.calledOnce(dispatcher.dispatch);
-        sinon.assert.calledWithExactly(dispatcher.dispatch,
-          new sharedActions.UpdateRoomInfo(_.extend({
-            roomInfoFailure: ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED,
-            roomState: ROOM_STATES.READY
-          }, expectedDetails)));
+        return store.fetchServerData(fetchServerAction).then(function() {
+          sinon.assert.called(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UpdateRoomInfo(_.extend({
+              roomInfoFailure: ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED,
+              roomState: ROOM_STATES.READY
+            }, expectedDetails)));
+        });
       });
 
       it("should dispatch UpdateRoomInfo message with 'no crypto key' failure if there is no crypto key", function() {
-        store.fetchServerData(fetchServerAction);
-
-        sinon.assert.calledOnce(dispatcher.dispatch);
-        sinon.assert.calledWithExactly(dispatcher.dispatch,
-          new sharedActions.UpdateRoomInfo(_.extend({
-            roomInfoFailure: ROOM_INFO_FAILURES.NO_CRYPTO_KEY,
-            roomState: ROOM_STATES.READY
-          }, expectedDetails)));
+        return store.fetchServerData(fetchServerAction).then(function() {
+          sinon.assert.called(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UpdateRoomInfo(_.extend({
+              roomInfoFailure: ROOM_INFO_FAILURES.NO_CRYPTO_KEY,
+              roomState: ROOM_STATES.READY
+            }, expectedDetails)));
+        });
       });
 
       it("should dispatch UpdateRoomInfo message with 'decrypt failed' failure if decryption failed", function() {
         fetchServerAction.cryptoKey = "fakeKey";
 
         // This is a work around to turn promise into a sync action to make handling test failures
         // easier.
         sandbox.stub(loop.crypto, "decryptBytes", function() {
           return {
             then: function(resolve, reject) {
               reject(new Error("Operation unsupported"));
             }
           };
         });
 
-        store.fetchServerData(fetchServerAction);
-
-        sinon.assert.calledOnce(dispatcher.dispatch);
-        sinon.assert.calledWithExactly(dispatcher.dispatch,
-          new sharedActions.UpdateRoomInfo(_.extend({
-            roomInfoFailure: ROOM_INFO_FAILURES.DECRYPT_FAILED,
-            roomState: ROOM_STATES.READY
-          }, expectedDetails)));
+        return store.fetchServerData(fetchServerAction).then(function() {
+          sinon.assert.called(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UpdateRoomInfo(_.extend({
+              roomInfoFailure: ROOM_INFO_FAILURES.DECRYPT_FAILED,
+              roomState: ROOM_STATES.READY
+            }, expectedDetails)));
+        });
       });
 
       it("should dispatch UpdateRoomInfo message with the context if decryption was successful", function() {
         fetchServerAction.cryptoKey = "fakeKey";
 
         var roomContext = {
           description: "Never gonna let you down. Never gonna give you up...",
           roomName: "The wonderful Loopy room",
@@ -553,28 +554,185 @@ describe("loop.store.ActiveRoomStore", f
         sandbox.stub(loop.crypto, "decryptBytes", function() {
           return {
             then: function(resolve, reject) {
               resolve(JSON.stringify(roomContext));
             }
           };
         });
 
-        store.fetchServerData(fetchServerAction);
+        return store.fetchServerData(fetchServerAction).then(function() {
+          var expectedData = _.extend({
+            roomContextUrls: roomContext.urls,
+            roomDescription: roomContext.description,
+            roomName: roomContext.roomName,
+            roomState: ROOM_STATES.READY
+          }, expectedDetails);
+
+          sinon.assert.called(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UpdateRoomInfo(expectedData));
+        });
+      });
+    });
+
+    describe("User Agent Room Handling", function() {
+      var channelListener, roomDetails;
+
+      beforeEach(function() {
+        sandbox.stub(sharedUtils, "isFirefox").returns(true);
+
+        roomDetails = {
+          roomName: "fakeName",
+          roomUrl: "http://invalid"
+        };
+        fakeMozLoop.rooms.get.callsArgWith(1, null, roomDetails);
+
+        sandbox.stub(window, "addEventListener", function(eventName, listener) {
+          if (eventName === "WebChannelMessageToContent") {
+            channelListener = listener;
+          }
+        });
+        sandbox.stub(window, "removeEventListener", function(eventName, listener) {
+          if (eventName === "WebChannelMessageToContent" &&
+              listener === channelListener) {
+            channelListener = null;
+          }
+        });
+      });
+
+      it("should dispatch UserAgentHandlesRoom with false if the user agent is not Firefox", function() {
+        sharedUtils.isFirefox.returns(false);
+
+        return store.fetchServerData(fetchServerAction).then(function() {
+          sinon.assert.called(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UserAgentHandlesRoom({
+              handlesRoom: false
+            }));
+        });
+      });
+
+      it("should dispatch with false after a timeout if there is no response from the channel", function() {
+        // When the dispatchEvent is called, we know the setup code has run, so
+        // advance the timer.
+        sandbox.stub(window, "dispatchEvent", function() {
+          sandbox.clock.tick(250);
+        });
+
+        return store.fetchServerData(fetchServerAction).then(function() {
+          sinon.assert.called(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UserAgentHandlesRoom({
+              handlesRoom: false
+            }));
+        });
+      });
 
-        var expectedData = _.extend({
-          roomContextUrls: roomContext.urls,
-          roomDescription: roomContext.description,
-          roomName: roomContext.roomName,
-          roomState: ROOM_STATES.READY
-        }, expectedDetails);
+      it("should not dispatch if a message is returned not for the link-clicker", function() {
+        // When the dispatchEvent is called, we know the setup code has run, so
+        // advance the timer.
+        sandbox.stub(window, "dispatchEvent", function() {
+          // We call the listener twice, but the first time with an invalid id.
+          // Hence we should only get the dispatch once.
+          channelListener({
+            detail: {
+              id: "invalid-id",
+              message: null
+            }
+          });
+          channelListener({
+            detail: {
+              id: "loop-link-clicker",
+              message: null
+            }
+          });
+        });
+
+        return store.fetchServerData(fetchServerAction).then(function() {
+          // Although this is only called once for the UserAgentHandlesRoom,
+          // it gets called twice due to the UpdateRoomInfo. Therefore,
+          // we test both results here.
+          sinon.assert.calledTwice(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UserAgentHandlesRoom({
+              handlesRoom: false
+            }));
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UpdateRoomInfo(_.extend({
+              roomState: ROOM_STATES.READY
+            }, roomDetails)));
+        });
+      });
+
+      it("should dispatch with false if the user agent does not understand the message", function() {
+        // When the dispatchEvent is called, we know the setup code has run, so
+        // advance the timer.
+        sandbox.stub(window, "dispatchEvent", function() {
+          channelListener({
+            detail: {
+              id: "loop-link-clicker",
+              message: null
+            }
+          });
+        });
 
-        sinon.assert.calledOnce(dispatcher.dispatch);
-        sinon.assert.calledWithExactly(dispatcher.dispatch,
-          new sharedActions.UpdateRoomInfo(expectedData));
+        return store.fetchServerData(fetchServerAction).then(function() {
+          sinon.assert.called(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UserAgentHandlesRoom({
+              handlesRoom: false
+            }));
+        });
+      });
+
+      it("should dispatch with false if the user agent cannot handle the message", function() {
+        // When the dispatchEvent is called, we know the setup code has run, so
+        // advance the timer.
+        sandbox.stub(window, "dispatchEvent", function() {
+          channelListener({
+            detail: {
+              id: "loop-link-clicker",
+              message: {
+                response: false
+              }
+            }
+          });
+        });
+
+        return store.fetchServerData(fetchServerAction).then(function() {
+          sinon.assert.called(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UserAgentHandlesRoom({
+              handlesRoom: false
+            }));
+        });
+      });
+
+      it("should dispatch with true if the user agent can handle the message", function() {
+        // When the dispatchEvent is called, we know the setup code has run, so
+        // advance the timer.
+        sandbox.stub(window, "dispatchEvent", function() {
+          channelListener({
+            detail: {
+              id: "loop-link-clicker",
+              message: {
+                response: true
+              }
+            }
+          });
+        });
+
+        return store.fetchServerData(fetchServerAction).then(function() {
+          sinon.assert.called(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.UserAgentHandlesRoom({
+              handlesRoom: true
+            }));
+        });
       });
     });
   });
 
   describe("#videoDimensionsChanged", function() {
     it("should not contain any video dimensions at the very start", function() {
       expect(store.getStoreState()).eql(store.getInitialStoreState());
     });
@@ -619,16 +777,30 @@ describe("loop.store.ActiveRoomStore", f
 
       var state = store.getStoreState();
       expect(state.roomName).eql(fakeRoomInfo.roomName);
       expect(state.roomUrl).eql(fakeRoomInfo.roomUrl);
       expect(state.roomContextUrls).eql(fakeRoomInfo.roomContextUrls);
     });
   });
 
+  describe("#userAgentHandlesRoom", function() {
+    it("should update the store state", function() {
+      store.setStoreState({
+        UserAgentHandlesRoom: false
+      });
+
+      store.userAgentHandlesRoom(new sharedActions.UserAgentHandlesRoom({
+        handlesRoom: true
+      }));
+
+      expect(store.getStoreState().userAgentHandlesRoom).eql(true);
+    });
+  });
+
   describe("#updateSocialShareInfo", function() {
     var fakeSocialShareInfo;
 
     beforeEach(function() {
       fakeSocialShareInfo = {
         socialShareProviders: [{
           name: "foo",
           origin: "https://example.com",
@@ -654,42 +826,148 @@ describe("loop.store.ActiveRoomStore", f
     it("should reset failureReason", function() {
       store.setStoreState({failureReason: "Test"});
 
       store.joinRoom();
 
       expect(store.getStoreState().failureReason).eql(undefined);
     });
 
-    it("should set the state to MEDIA_WAIT if media devices are present", function() {
-      sandbox.stub(loop.shared.utils, "hasAudioOrVideoDevices").callsArgWith(0, true);
+    describe("Standalone Handles Room", function() {
+      it("should dispatch a MetricsLogJoinRoom action", function() {
+        store.joinRoom();
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.MetricsLogJoinRoom({
+            userAgentHandledRoom: false
+          }));
+      });
+
+      it("should set the state to MEDIA_WAIT if media devices are present", function() {
+        sandbox.stub(loop.shared.utils, "hasAudioOrVideoDevices").callsArgWith(0, true);
+
+        store.joinRoom();
+
+        expect(store.getStoreState().roomState).eql(ROOM_STATES.MEDIA_WAIT);
+      });
 
-      store.joinRoom();
+      it("should not set the state to MEDIA_WAIT if no media devices are present", function() {
+        sandbox.stub(loop.shared.utils, "hasAudioOrVideoDevices").callsArgWith(0, false);
+
+        store.joinRoom();
+
+        expect(store.getStoreState().roomState).eql(ROOM_STATES.READY);
+      });
 
-      expect(store.getStoreState().roomState).eql(ROOM_STATES.MEDIA_WAIT);
+      it("should dispatch `ConnectionFailure` if no media devices are present", function() {
+        sandbox.stub(loop.shared.utils, "hasAudioOrVideoDevices").callsArgWith(0, false);
+
+        store.joinRoom();
+
+        sinon.assert.called(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.ConnectionFailure({
+            reason: FAILURE_DETAILS.NO_MEDIA
+          }));
+      });
     });
 
-    it("should not set the state to MEDIA_WAIT if no media devices are present", function() {
-      sandbox.stub(loop.shared.utils, "hasAudioOrVideoDevices").callsArgWith(0, false);
+    describe("Firefox Handles Room", function() {
+      var channelListener;
+
+      beforeEach(function() {
+        store.setStoreState({
+          userAgentHandlesRoom: true,
+          roomToken: "fakeToken",
+          standalone: true
+        });
 
-      store.joinRoom();
+        sandbox.stub(window, "addEventListener", function(eventName, listener) {
+          if (eventName === "WebChannelMessageToContent") {
+            channelListener = listener;
+          }
+        });
+        sandbox.stub(window, "removeEventListener", function(eventName, listener) {
+          if (eventName === "WebChannelMessageToContent" &&
+              listener === channelListener) {
+            channelListener = null;
+          }
+        });
+
+        sandbox.stub(console, "error");
+      });
 
-      expect(store.getStoreState().roomState).eql(ROOM_STATES.READY);
-    });
+      it("should dispatch a MetricsLogJoinRoom action", function() {
+        store.joinRoom();
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.MetricsLogJoinRoom({
+            userAgentHandledRoom: true,
+            ownRoom: true
+          }));
+      });
+
+      it("should dispatch an event to Firefox", function() {
+        sandbox.stub(window, "dispatchEvent");
+
+        store.joinRoom();
 
-    it("should dispatch `ConnectionFailure` if no media devices are present", function() {
-      sandbox.stub(loop.shared.utils, "hasAudioOrVideoDevices").callsArgWith(0, false);
+        sinon.assert.calledOnce(window.dispatchEvent);
+        sinon.assert.calledWithExactly(window.dispatchEvent, new window.CustomEvent(
+          "WebChannelMessageToChrome", {
+          detail: {
+            id: "loop-link-clicker",
+            message: {
+              command: "openRoom",
+              roomToken: "fakeToken"
+            }
+          }
+        }));
+      });
 
-      store.joinRoom();
+      it("should log an error if Firefox doesn't handle the room", function() {
+        // Start the join.
+        store.joinRoom();
+
+        // Pretend Firefox calls back.
+        channelListener({
+          detail: {
+            id: "loop-link-clicker",
+            message: null
+          }
+        });
 
-      sinon.assert.calledOnce(dispatcher.dispatch);
-      sinon.assert.calledWithExactly(dispatcher.dispatch,
-        new sharedActions.ConnectionFailure({
-          reason: FAILURE_DETAILS.NO_MEDIA
-        }));
+        sinon.assert.calledOnce(console.error);
+      });
+
+      it("should dispatch a JoinedRoom action if the room was successfully opened", function() {
+        // Start the join.
+        store.joinRoom();
+
+        // Pretend Firefox calls back.
+        channelListener({
+          detail: {
+            id: "loop-link-clicker",
+            message: {
+              response: true
+            }
+          }
+        });
+
+        sinon.assert.called(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.JoinedRoom({
+            apiKey: "",
+            sessionToken: "",
+            sessionId: "",
+            expires: 0
+          }));
+      });
     });
   });
 
   describe("#gotMediaPermission", function() {
     beforeEach(function() {
       store.setStoreState({roomToken: "tokenFake"});
     });
 
@@ -757,25 +1035,50 @@ describe("loop.store.ActiveRoomStore", f
     });
 
     it("should set the state to `JOINED`", function() {
       store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
 
       expect(store._storeState.roomState).eql(ROOM_STATES.JOINED);
     });
 
+    it("should set the state to `JOINED` when Firefox handles the room", function() {
+      store.setStoreState({
+        userAgentHandlesRoom: true,
+        standalone: true
+      });
+
+      store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
+
+      expect(store._storeState.roomState).eql(ROOM_STATES.JOINED);
+    });
+
     it("should store the session and api values", function() {
       store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
 
       var state = store.getStoreState();
       expect(state.apiKey).eql(fakeJoinedData.apiKey);
       expect(state.sessionToken).eql(fakeJoinedData.sessionToken);
       expect(state.sessionId).eql(fakeJoinedData.sessionId);
     });
 
+    it("should not store the session and api values when Firefox handles the room", function() {
+      store.setStoreState({
+        userAgentHandlesRoom: true,
+        standalone: true
+      });
+
+      store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
+
+      var state = store.getStoreState();
+      expect(state.apiKey).eql(undefined);
+      expect(state.sessionToken).eql(undefined);
+      expect(state.sessionId).eql(undefined);
+    });
+
     it("should start the session connection with the sdk", function() {
       var actionData = new sharedActions.JoinedRoom(fakeJoinedData);
 
       store.joinedRoom(actionData);
 
       sinon.assert.calledOnce(fakeSdkDriver.connectSession);
       sinon.assert.calledWithExactly(fakeSdkDriver.connectSession,
         actionData);
--- a/browser/components/loop/test/standalone/standaloneMetricsStore_test.js
+++ b/browser/components/loop/test/standalone/standaloneMetricsStore_test.js
@@ -81,25 +81,16 @@ describe("loop.store.StandaloneMetricsSt
       store.gotMediaPermission();
 
       sinon.assert.calledOnce(window.ga);
       sinon.assert.calledWithExactly(window.ga,
         "send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.success,
         "Media granted");
     });
 
-    it("should log an event on JoinRoom", function() {
-      store.joinRoom();
-
-      sinon.assert.calledOnce(window.ga);
-      sinon.assert.calledWithExactly(window.ga,
-        "send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
-        "Join the conversation");
-    });
-
     it("should log an event on JoinedRoom", function() {
       store.joinedRoom();
 
       sinon.assert.calledOnce(window.ga);
       sinon.assert.calledWithExactly(window.ga,
         "send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.success,
         "Joined room");
     });
@@ -145,16 +136,53 @@ describe("loop.store.StandaloneMetricsSt
     it("should log an event on RetryAfterRoomFailure", function() {
       store.retryAfterRoomFailure();
 
       sinon.assert.calledOnce(window.ga);
       sinon.assert.calledWithExactly(window.ga,
         "send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
         "Retry failed room");
     });
+
+    describe("MetricsLogJoinRoom", function() {
+      it("should log a 'Join the conversation' event if not joined by Firefox", function() {
+        store.metricsLogJoinRoom({
+          userAgentHandledRoom: false
+        });
+
+        sinon.assert.calledOnce(window.ga);
+        sinon.assert.calledWithExactly(window.ga,
+          "send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
+          "Join the conversation");
+      });
+
+      it("should log a 'Joined own room in Firefox' event if joining the own room in Firefox", function() {
+        store.metricsLogJoinRoom({
+          userAgentHandledRoom: true,
+          ownRoom: true
+        });
+
+        sinon.assert.calledOnce(window.ga);
+        sinon.assert.calledWithExactly(window.ga,
+          "send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
+          "Joined own room in Firefox");
+      });
+
+      it("should log a 'Joined in Firefox' event if joining a non-own room in Firefox", function() {
+        store.metricsLogJoinRoom({
+          userAgentHandledRoom: true,
+          ownRoom: false
+        });
+
+        sinon.assert.calledOnce(window.ga);
+        sinon.assert.calledWithExactly(window.ga,
+          "send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
+          "Joined in Firefox");
+      });
+    });
   });
 
   describe("Store Change Handlers", function() {
     it("should log an event on room full", function() {
       fakeActiveRoomStore.setStoreState({roomState: ROOM_STATES.FULL});
 
       sinon.assert.calledOnce(window.ga);
       sinon.assert.calledWithExactly(window.ga,
--- a/browser/components/loop/test/standalone/standaloneRoomViews_test.js
+++ b/browser/components/loop/test/standalone/standaloneRoomViews_test.js
@@ -123,16 +123,73 @@ describe("loop.standaloneRoomViews", fun
 
     it("should not dispatch an action when the text is clicked", function() {
       TestUtils.Simulate.click(node, { target: node });
 
       sinon.assert.notCalled(dispatcher.dispatch);
     });
   });
 
+  describe("StandaloneHandleUserAgentView", function() {
+    function mountTestComponent() {
+      return TestUtils.renderIntoDocument(
+        React.createElement(
+          loop.standaloneRoomViews.StandaloneHandleUserAgentView, {
+            dispatcher: dispatcher
+          }));
+    }
+
+    it("should display a join room button if the state is not ROOM_JOINED", function() {
+      activeRoomStore.setStoreState({
+        roomState: ROOM_STATES.READY
+      });
+
+      view = mountTestComponent();
+      var button = view.getDOMNode().querySelector(".info-panel > button");
+
+      expect(button.textContent).eql("rooms_room_join_label");
+    });
+
+    it("should dispatch a JoinRoom action when the join room button is clicked", function() {
+      activeRoomStore.setStoreState({
+        roomState: ROOM_STATES.READY
+      });
+
+      view = mountTestComponent();
+      var button = view.getDOMNode().querySelector(".info-panel > button");
+
+      TestUtils.Simulate.click(button);
+
+      sinon.assert.calledOnce(dispatcher.dispatch);
+      sinon.assert.calledWithExactly(dispatcher.dispatch, new sharedActions.JoinRoom());
+    });
+
+    it("should display a enjoy your conversation button if the state is ROOM_JOINED", function() {
+      activeRoomStore.setStoreState({
+        roomState: ROOM_STATES.JOINED
+      });
+
+      view = mountTestComponent();
+      var button = view.getDOMNode().querySelector(".info-panel > button");
+
+      expect(button.textContent).eql("rooms_room_joined_own_conversation_label");
+    });
+
+    it("should disable the enjoy your conversation button if the state is ROOM_JOINED", function() {
+      activeRoomStore.setStoreState({
+        roomState: ROOM_STATES.JOINED
+      });
+
+      view = mountTestComponent();
+      var button = view.getDOMNode().querySelector(".info-panel > button");
+
+      expect(button.classList.contains("disabled")).eql(true);
+    });
+  });
+
   describe("StandaloneRoomHeader", function() {
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         React.createElement(
           loop.standaloneRoomViews.StandaloneRoomHeader, {
             dispatcher: dispatcher
           }));
     }
@@ -861,9 +918,52 @@ describe("loop.standaloneRoomViews", fun
             });
 
             expect(view.getDOMNode().querySelector(".local .avatar")).not.eql(
               null);
           });
       });
     });
   });
+
+  describe("StandaloneRoomControllerView", function() {
+    function mountTestComponent() {
+      return TestUtils.renderIntoDocument(
+        React.createElement(
+          loop.standaloneRoomViews.StandaloneRoomControllerView, {
+        dispatcher: dispatcher,
+        isFirefox: true
+      }));
+    }
+
+    it("should not display anything if it is not known if Firefox can handle the room", function() {
+      activeRoomStore.setStoreState({
+        firefoxHandlesRoom: undefined
+      });
+
+      view = mountTestComponent();
+
+      expect(view.getDOMNode()).eql(null);
+    });
+
+    it("should render StandaloneHandleUserAgentView if Firefox can handle the room", function() {
+      activeRoomStore.setStoreState({
+        firefoxHandlesRoom: true
+      });
+
+      view = mountTestComponent();
+
+      TestUtils.findRenderedComponentWithType(view,
+        loop.standaloneRoomViews.StandaloneHandleUserAgentView);
+    });
+
+    it("should render StandaloneRoomView if Firefox cannot handle the room", function() {
+      activeRoomStore.setStoreState({
+        firefoxHandlesRoom: false
+      });
+
+      view = mountTestComponent();
+
+      TestUtils.findRenderedComponentWithType(view,
+        loop.standaloneRoomViews.StandaloneRoomView);
+    });
+  });
 });
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -114,24 +114,24 @@ describe("loop.webapp", function() {
         standaloneAppStore.setStoreState({windowType: "unsupportedBrowser", isFirefox: false});
 
         var webappRootView = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(webappRootView,
           loop.webapp.UnsupportedBrowserView);
       });
 
-    it("should display the StandaloneRoomView for `room` window type",
+    it("should display the StandaloneRoomControllerView for `room` window type",
       function() {
         standaloneAppStore.setStoreState({windowType: "room", isFirefox: true});
 
         var webappRootView = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(webappRootView,
-          loop.standaloneRoomViews.StandaloneRoomView);
+          loop.standaloneRoomViews.StandaloneRoomControllerView);
       });
 
     it("should display the HomeView for `home` window type", function() {
         standaloneAppStore.setStoreState({windowType: "home", isFirefox: true});
 
         var webappRootView = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(webappRootView,
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -31,16 +31,17 @@
   var RoomFailureView = loop.roomViews.RoomFailureView;
   var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView  = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView   = loop.webapp.UnsupportedDeviceView;
   var StandaloneRoomView      = loop.standaloneRoomViews.StandaloneRoomView;
+  var StandaloneHandleUserAgentView = loop.standaloneRoomViews.StandaloneHandleUserAgentView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var FeedbackView = loop.feedbackViews.FeedbackView;
   var Checkbox = loop.shared.views.Checkbox;
   var TextChatView = loop.shared.views.chat.TextChatView;
 
   // Store constants
@@ -1510,16 +1511,31 @@
                   mozLoop: navigator.mozLoop, 
                   onCallTerminated: function(){}, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   roomStore: desktopRemoteFaceMuteRoomStore})
               )
             )
           ), 
 
+          React.createElement(Section, {name: "StandaloneHandleUserAgentView"}, 
+            React.createElement(FramedExample, {
+              cssClass: "standalone", 
+              dashed: true, 
+              height: 483, 
+              summary: "Standalone Room Handle Join in Firefox", 
+              width: 644}, 
+              React.createElement("div", {className: "standalone"}, 
+                React.createElement(StandaloneHandleUserAgentView, {
+                  activeRoomStore: readyRoomStore, 
+                  dispatcher: dispatcher})
+              )
+            )
+          ), 
+
           React.createElement(Section, {name: "StandaloneRoomView"}, 
             React.createElement(FramedExample, {cssClass: "standalone", 
                            dashed: true, 
                            height: 483, 
                            summary: "Standalone room conversation (ready)", 
                            width: 644}, 
               React.createElement("div", {className: "standalone"}, 
                 React.createElement(StandaloneRoomView, {
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -31,16 +31,17 @@
   var RoomFailureView = loop.roomViews.RoomFailureView;
   var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView  = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView   = loop.webapp.UnsupportedDeviceView;
   var StandaloneRoomView      = loop.standaloneRoomViews.StandaloneRoomView;
+  var StandaloneHandleUserAgentView = loop.standaloneRoomViews.StandaloneHandleUserAgentView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var FeedbackView = loop.feedbackViews.FeedbackView;
   var Checkbox = loop.shared.views.Checkbox;
   var TextChatView = loop.shared.views.chat.TextChatView;
 
   // Store constants
@@ -1510,16 +1511,31 @@
                   mozLoop={navigator.mozLoop}
                   onCallTerminated={function(){}}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   roomStore={desktopRemoteFaceMuteRoomStore} />
               </div>
             </FramedExample>
           </Section>
 
+          <Section name="StandaloneHandleUserAgentView">
+            <FramedExample
+              cssClass="standalone"
+              dashed={true}
+              height={483}
+              summary="Standalone Room Handle Join in Firefox"
+              width={644} >
+              <div className="standalone">
+                <StandaloneHandleUserAgentView
+                  activeRoomStore={readyRoomStore}
+                  dispatcher={dispatcher} />
+              </div>
+            </FramedExample>
+          </Section>
+
           <Section name="StandaloneRoomView">
             <FramedExample cssClass="standalone"
                            dashed={true}
                            height={483}
                            summary="Standalone room conversation (ready)"
                            width={644} >
               <div className="standalone">
                 <StandaloneRoomView