Bug 1097742 - Part 1 Handle access being denied to media, and prevent the sdk prompts from showing in Loop Rooms. r=nperriault a=loop-only
authorMark Banner <standard8@mozilla.com>
Thu, 13 Nov 2014 22:45:23 +0000
changeset 233930 9fe7dd3fcb200398f052357f9bf895ef84bf4bdf
parent 233929 b92ec32f2344acd12bb9ec4d2580ff5251599a4a
child 233931 00e65cb7f78043180f49274a7a9e80f8d4486649
push id4187
push userbhearsum@mozilla.com
push dateFri, 28 Nov 2014 15:29:12 +0000
treeherdermozilla-beta@f23cc6a30c11 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnperriault, loop-only
bugs1097742
milestone35.0a2
Bug 1097742 - Part 1 Handle access being denied to media, and prevent the sdk prompts from showing in Loop Rooms. r=nperriault a=loop-only
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/content/shared/js/otSdkDriver.js
browser/components/loop/content/shared/js/utils.js
browser/components/loop/standalone/content/js/standaloneRoomViews.js
browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
browser/components/loop/standalone/content/l10n/loop.en-US.properties
browser/components/loop/test/desktop-local/roomViews_test.js
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/shared/otSdkDriver_test.js
browser/components/loop/test/standalone/standaloneRoomViews_test.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -5,16 +5,25 @@
 /* global loop:true */
 
 var loop = loop || {};
 loop.store = loop.store || {};
 loop.store.ActiveRoomStore = (function() {
   "use strict";
 
   var sharedActions = loop.shared.actions;
+  var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
+
+  // Error numbers taken from
+  // https://github.com/mozilla-services/loop-server/blob/master/loop/errno.json
+  var SERVER_CODES = loop.store.SERVER_CODES = {
+    INVALID_TOKEN: 105,
+    EXPIRED: 111,
+    ROOM_FULL: 202
+  };
 
   var ROOM_STATES = loop.store.ROOM_STATES = {
     // The initial state of the room
     INIT: "room-init",
     // The store is gathering the room data
     GATHER: "room-gather",
     // The store has got the room data
     READY: "room-ready",
@@ -79,17 +88,18 @@ loop.store.ActiveRoomStore = (function()
      * @property {ROOM_STATES} roomState - the state of the room.
      * @property {Error=} error - if the room is an error state, this will be
      *                            set to an Error object reflecting the problem;
      *                            otherwise it will be unset.
      */
     this._storeState = {
       roomState: ROOM_STATES.INIT,
       audioMuted: false,
-      videoMuted: false
+      videoMuted: false,
+      failureReason: undefined
     };
   }
 
   ActiveRoomStore.prototype = _.extend({
     /**
      * The time factor to adjust the expires time to ensure that we send a refresh
      * before the expiry. Currently set as 90%.
      */
@@ -107,23 +117,34 @@ loop.store.ActiveRoomStore = (function()
     },
 
     /**
      * Handles a room failure.
      *
      * @param {sharedActions.RoomFailure} actionData
      */
     roomFailure: function(actionData) {
+      function getReason(serverCode) {
+        switch (serverCode) {
+          case SERVER_CODES.INVALID_TOKEN:
+          case SERVER_CODES.EXPIRED:
+            return FAILURE_REASONS.EXPIRED_OR_INVALID;
+          default:
+            return FAILURE_REASONS.UNKNOWN;
+        }
+      }
+
       console.error("Error in state `" + this._storeState.roomState + "`:",
         actionData.error);
 
       this.setStoreState({
         error: actionData.error,
-        roomState: actionData.error.errno === 202 ? ROOM_STATES.FULL
-                                                  : ROOM_STATES.FAILED
+        failureReason: getReason(actionData.error.errno),
+        roomState: actionData.error.errno === SERVER_CODES.ROOM_FULL ?
+          ROOM_STATES.FULL : ROOM_STATES.FAILED
       });
     },
 
     /**
      * Registers the actions with the dispatcher that this store is interested
      * in.
      */
     _registerActions: function() {
@@ -223,16 +244,21 @@ loop.store.ActiveRoomStore = (function()
         roomUrl: actionData.roomUrl
       });
     },
 
     /**
      * Handles the action to join to a room.
      */
     joinRoom: function() {
+      // Reset the failure reason if necessary.
+      if (this.getStoreState().failureReason) {
+        this.setStoreState({failureReason: undefined});
+      }
+
       this._mozLoop.rooms.join(this._storeState.roomToken,
         function(error, responseData) {
           if (error) {
             this._dispatcher.dispatch(
               new sharedActions.RoomFailure({error: error}));
             return;
           }
 
@@ -270,21 +296,27 @@ loop.store.ActiveRoomStore = (function()
     connectedToSdkServers: function() {
       this.setStoreState({
         roomState: ROOM_STATES.SESSION_CONNECTED
       });
     },
 
     /**
      * Handles disconnection of this local client from the sdk servers.
+     *
+     * @param {sharedActions.ConnectionFailure} actionData
      */
-    connectionFailure: function() {
+    connectionFailure: function(actionData) {
       // Treat all reasons as something failed. In theory, clientDisconnected
       // could be a success case, but there's no way we should be intentionally
       // sending that and still have the window open.
+      this.setStoreState({
+        failureReason: actionData.reason
+      });
+
       this._leaveRoom(ROOM_STATES.FAILED);
     },
 
     /**
      * Records the mute state for the stream.
      *
      * @param {sharedActions.setMute} actionData The mute state for the stream type.
      */
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -3,16 +3,17 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true */
 
 var loop = loop || {};
 loop.OTSdkDriver = (function() {
 
   var sharedActions = loop.shared.actions;
+  var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
 
   /**
    * This is a wrapper for the OT sdk. It is used to translate the SDK events into
    * actions, and instruct the SDK what to do as a result of actions.
    */
   var OTSdkDriver = function(options) {
       if (!options.dispatcher) {
         throw new Error("Missing option dispatcher");
@@ -42,18 +43,21 @@ loop.OTSdkDriver = (function() {
       this.getLocalElement = actionData.getLocalElementFunc;
       this.getRemoteElement = actionData.getRemoteElementFunc;
       this.publisherConfig = actionData.publisherConfig;
 
       // At this state we init the publisher, even though we might be waiting for
       // the initial connect of the session. This saves time when setting up
       // the media.
       this.publisher = this.sdk.initPublisher(this.getLocalElement(),
-        this.publisherConfig,
-        this._onPublishComplete.bind(this));
+        this.publisherConfig);
+      this.publisher.on("accessAllowed", this._onPublishComplete.bind(this));
+      this.publisher.on("accessDenied", this._onPublishDenied.bind(this));
+      this.publisher.on("accessDialogOpened",
+        this._onAccessDialogOpened.bind(this));
     },
 
     /**
      * Handles the setMute action. Informs the published stream to mute
      * or unmute audio as appropriate.
      *
      * @param {sharedActions.SetMute} actionData The data associated with the
      *                                           action. See action.js.
@@ -91,26 +95,22 @@ loop.OTSdkDriver = (function() {
         this._onConnectionComplete.bind(this));
     },
 
     /**
      * Disconnects the sdk session.
      */
     disconnectSession: function() {
       if (this.session) {
-        this.session.off("streamCreated", this._onRemoteStreamCreated.bind(this));
-        this.session.off("connectionDestroyed",
-          this._onConnectionDestroyed.bind(this));
-        this.session.off("sessionDisconnected",
-          this._onSessionDisconnected.bind(this));
-
+        this.session.off("streamCreated connectionDestroyed sessionDisconnected");
         this.session.disconnect();
         delete this.session;
       }
       if (this.publisher) {
+        this.publisher.off("accessAllowed accessDenied accessDialogOpened");
         this.publisher.destroy();
         delete this.publisher;
       }
 
       // Also, tidy these variables ready for next time.
       delete this._sessionConnected;
       delete this._publisherReady;
       delete this._publishedLocalStream;
@@ -121,17 +121,17 @@ loop.OTSdkDriver = (function() {
      * Called once the session has finished connecting.
      *
      * @param {Error} error An OT error object, null if there was no error.
      */
     _onConnectionComplete: function(error) {
       if (error) {
         console.error("Failed to complete connection", error);
         this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
-          reason: "couldNotConnect"
+          reason: FAILURE_REASONS.COULD_NOT_CONNECT
         }));
         return;
       }
 
       this.dispatcher.dispatch(new sharedActions.ConnectedToSdkServers());
       this._sessionConnected = true;
       this._maybePublishLocalStream();
     },
@@ -154,17 +154,17 @@ loop.OTSdkDriver = (function() {
      *
      * @param {SessionDisconnectEvent} event The event details:
      * https://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html
      */
     _onSessionDisconnected: function(event) {
       // We only need to worry about the network disconnected reason here.
       if (event.reason === "networkDisconnected") {
         this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
-          reason: "networkDisconnected"
+          reason: FAILURE_REASONS.NETWORK_DISCONNECTED
         }));
       }
     },
 
     _onConnectionCreated: function(event) {
       if (this.session.connection.id === event.connection.id) {
         return;
       }
@@ -184,34 +184,52 @@ loop.OTSdkDriver = (function() {
 
       this._subscribedRemoteStream = true;
       if (this._checkAllStreamsConnected()) {
         this.dispatcher.dispatch(new sharedActions.MediaConnected());
       }
     },
 
     /**
+     * Called from the sdk when the media access dialog is opened.
+     * Prevents the default action, to prevent the SDK's "allow access"
+     * dialog from being shown.
+     *
+     * @param {OT.Event} event
+     */
+    _onAccessDialogOpened: function(event) {
+      event.preventDefault();
+    },
+
+    /**
      * Handles the publishing being complete.
      *
-     * @param {Error} error An OT error object, null if there was no error.
+     * @param {OT.Event} event
      */
-    _onPublishComplete: function(error) {
-      if (error) {
-        console.error("Failed to initialize publisher", error);
-        this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
-          reason: "noMedia"
-        }));
-        return;
-      }
-
+    _onPublishComplete: function(event) {
+      event.preventDefault();
       this._publisherReady = true;
       this._maybePublishLocalStream();
     },
 
     /**
+     * Handles publishing of media being denied.
+     *
+     * @param {OT.Event} event
+     */
+    _onPublishDenied: function(event) {
+      // This prevents the SDK's "access denied" dialog showing.
+      event.preventDefault();
+
+      this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+        reason: FAILURE_REASONS.MEDIA_DENIED
+      }));
+    },
+
+    /**
      * Publishes the local stream if the session is connected
      * and the publisher is ready.
      */
     _maybePublishLocalStream: function() {
       if (this._sessionConnected && this._publisherReady) {
         // We are clear to publish the stream to the session.
         this.session.publish(this.publisher);
 
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -12,16 +12,24 @@ loop.shared.utils = (function(mozL10n) {
   /**
    * Call types used for determining if a call is audio/video or audio-only.
    */
   var CALL_TYPES = {
     AUDIO_VIDEO: "audio-video",
     AUDIO_ONLY: "audio"
   };
 
+  var FAILURE_REASONS = {
+    MEDIA_DENIED: "reason-media-denied",
+    COULD_NOT_CONNECT: "reason-could-not-connect",
+    NETWORK_DISCONNECTED: "reason-network-disconnected",
+    EXPIRED_OR_INVALID: "reason-expired-or-invalid",
+    UNKNOWN: "reason-unknown"
+  };
+
   /**
    * Format a given date into an l10n-friendly string.
    *
    * @param {Integer} The timestamp in seconds to format.
    * @return {String} The formatted string.
    */
   function formatDate(timestamp) {
     var date = (new Date(timestamp * 1000));
@@ -105,14 +113,15 @@ loop.shared.utils = (function(mozL10n) {
         learnMoreUrl: navigator.mozLoop.getLoopCharPref("learnMoreUrl")
       }),
       recipient
     );
   }
 
   return {
     CALL_TYPES: CALL_TYPES,
+    FAILURE_REASONS: FAILURE_REASONS,
     Helper: Helper,
     composeCallUrlEmail: composeCallUrlEmail,
     formatDate: formatDate,
     getBoolPreference: getBoolPreference
   };
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -6,16 +6,17 @@
 
 /* global loop:true, React */
 /* jshint newcap:false, maxlen:false */
 
 var loop = loop || {};
 loop.standaloneRoomViews = (function(mozL10n) {
   "use strict";
 
+  var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedViews = loop.shared.views;
 
   var StandaloneRoomInfoArea = React.createClass({displayName: 'StandaloneRoomInfoArea',
     propTypes: {
       helper: React.PropTypes.instanceOf(loop.shared.utils.Helper).isRequired
@@ -35,16 +36,30 @@ loop.standaloneRoomViews = (function(moz
         React.DOM.a({href: loop.config.brandWebsiteUrl, className: "btn btn-info"}, 
           mozL10n.get("rooms_room_full_call_to_action_nonFx_label", {
             brandShortname: mozL10n.get("brandShortname")
           })
         )
       );
     },
 
+    /**
+     * @return String An appropriate string according to the failureReason.
+     */
+    _getFailureString: function() {
+      switch(this.props.failureReason) {
+        case FAILURE_REASONS.MEDIA_DENIED:
+          return mozL10n.get("rooms_media_denied_message");
+        case FAILURE_REASONS.EXPIRED_OR_INVALID:
+          return mozL10n.get("rooms_unavailable_notification_message");
+        default:
+          return mozL10n.get("status_error");
+      };
+    },
+
     _renderContent: function() {
       switch(this.props.roomState) {
         case ROOM_STATES.INIT:
         case ROOM_STATES.READY: {
           return (
             React.DOM.button({className: "btn btn-join btn-info", 
                     onClick: this.props.joinRoom}, 
               mozL10n.get("rooms_room_join_label")
@@ -63,16 +78,22 @@ loop.standaloneRoomViews = (function(moz
           return (
             React.DOM.div(null, 
               React.DOM.p({className: "full-room-message"}, 
                 mozL10n.get("rooms_room_full_label")
               ), 
               React.DOM.p(null, this._renderCallToActionLink())
             )
           );
+        case ROOM_STATES.FAILED:
+          return (
+            React.DOM.p({className: "failed-room-message"}, 
+              this._getFailureString()
+            )
+          );
         default:
           return null;
       }
     },
 
     render: function() {
       return (
         React.DOM.div({className: "room-inner-info-area"}, 
@@ -247,16 +268,17 @@ loop.standaloneRoomViews = (function(moz
         "local-stream": true,
         "local-stream-audio": false
       });
 
       return (
         React.DOM.div({className: "room-conversation-wrapper"}, 
           StandaloneRoomHeader(null), 
           StandaloneRoomInfoArea({roomState: this.state.roomState, 
+                                  failureReason: this.state.failureReason, 
                                   joinRoom: this.joinRoom, 
                                   helper: this.props.helper}), 
           React.DOM.div({className: "video-layout-wrapper"}, 
             React.DOM.div({className: "conversation room-conversation"}, 
               React.DOM.h2({className: "room-name"}, this.state.roomName), 
               React.DOM.div({className: "media nested"}, 
                 React.DOM.div({className: "video_wrapper remote_wrapper"}, 
                   React.DOM.div({className: "video_inner remote"})
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -6,16 +6,17 @@
 
 /* global loop:true, React */
 /* jshint newcap:false, maxlen:false */
 
 var loop = loop || {};
 loop.standaloneRoomViews = (function(mozL10n) {
   "use strict";
 
+  var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedViews = loop.shared.views;
 
   var StandaloneRoomInfoArea = React.createClass({
     propTypes: {
       helper: React.PropTypes.instanceOf(loop.shared.utils.Helper).isRequired
@@ -35,16 +36,30 @@ loop.standaloneRoomViews = (function(moz
         <a href={loop.config.brandWebsiteUrl} className="btn btn-info">
           {mozL10n.get("rooms_room_full_call_to_action_nonFx_label", {
             brandShortname: mozL10n.get("brandShortname")
           })}
         </a>
       );
     },
 
+    /**
+     * @return String An appropriate string according to the failureReason.
+     */
+    _getFailureString: function() {
+      switch(this.props.failureReason) {
+        case FAILURE_REASONS.MEDIA_DENIED:
+          return mozL10n.get("rooms_media_denied_message");
+        case FAILURE_REASONS.EXPIRED_OR_INVALID:
+          return mozL10n.get("rooms_unavailable_notification_message");
+        default:
+          return mozL10n.get("status_error");
+      };
+    },
+
     _renderContent: function() {
       switch(this.props.roomState) {
         case ROOM_STATES.INIT:
         case ROOM_STATES.READY: {
           return (
             <button className="btn btn-join btn-info"
                     onClick={this.props.joinRoom}>
               {mozL10n.get("rooms_room_join_label")}
@@ -63,16 +78,22 @@ loop.standaloneRoomViews = (function(moz
           return (
             <div>
               <p className="full-room-message">
                 {mozL10n.get("rooms_room_full_label")}
               </p>
               <p>{this._renderCallToActionLink()}</p>
             </div>
           );
+        case ROOM_STATES.FAILED:
+          return (
+            <p className="failed-room-message">
+              {this._getFailureString()}
+            </p>
+          );
         default:
           return null;
       }
     },
 
     render: function() {
       return (
         <div className="room-inner-info-area">
@@ -247,16 +268,17 @@ loop.standaloneRoomViews = (function(moz
         "local-stream": true,
         "local-stream-audio": false
       });
 
       return (
         <div className="room-conversation-wrapper">
           <StandaloneRoomHeader />
           <StandaloneRoomInfoArea roomState={this.state.roomState}
+                                  failureReason={this.state.failureReason}
                                   joinRoom={this.joinRoom}
                                   helper={this.props.helper} />
           <div className="video-layout-wrapper">
             <div className="conversation room-conversation">
               <h2 className="room-name">{this.state.roomName}</h2>
               <div className="media nested">
                 <div className="video_wrapper remote_wrapper">
                   <div className="video_inner remote"></div>
--- a/browser/components/loop/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -110,8 +110,11 @@ rooms_name_this_room_label=Name this con
 rooms_new_room_button_label=Start a conversation
 rooms_only_occupant_label=You're the first 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_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.
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -60,16 +60,17 @@ describe("loop.roomViews", function () {
       var testView = TestUtils.renderIntoDocument(TestView({
         roomStore: roomStore
       }));
 
       expect(testView.state).eql({
         roomState: ROOM_STATES.INIT,
         audioMuted: false,
         videoMuted: false,
+        failureReason: undefined
         foo: "bar"
       });
     });
 
     it("should listen to store changes", function() {
       var TestView = React.createClass({
         mixins: [loop.roomViews.ActiveRoomStoreMixin],
         render: function() { return React.DOM.div(); }
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -1,17 +1,19 @@
 /* global chai, loop */
 
 var expect = chai.expect;
 var sharedActions = loop.shared.actions;
 
 describe("loop.store.ActiveRoomStore", function () {
   "use strict";
 
+  var SERVER_CODES = loop.store.SERVER_CODES;
   var ROOM_STATES = loop.store.ROOM_STATES;
+  var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
   var sandbox, dispatcher, store, fakeMozLoop, fakeSdkDriver;
   var fakeMultiplexGum;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     sandbox.useFakeTimers();
 
     dispatcher = new loop.Dispatcher();
@@ -86,29 +88,50 @@ describe("loop.store.ActiveRoomStore", f
     it("should log the error", function() {
       store.roomFailure({error: fakeError});
 
       sinon.assert.calledOnce(console.error);
       sinon.assert.calledWith(console.error,
         sinon.match(ROOM_STATES.READY), fakeError);
     });
 
-    it("should set the state to `FULL` on server errno 202", function() {
-      fakeError.errno = 202;
+    it("should set the state to `FULL` on server error room full", function() {
+      fakeError.errno = SERVER_CODES.ROOM_FULL;
 
       store.roomFailure({error: fakeError});
 
       expect(store._storeState.roomState).eql(ROOM_STATES.FULL);
     });
 
     it("should set the state to `FAILED` on generic error", function() {
       store.roomFailure({error: fakeError});
 
       expect(store._storeState.roomState).eql(ROOM_STATES.FAILED);
+      expect(store._storeState.failureReason).eql(FAILURE_REASONS.UNKNOWN);
     });
+
+    it("should set the failureReason to EXPIRED_OR_INVALID on server error: " +
+      "invalid token", function() {
+        fakeError.errno = SERVER_CODES.INVALID_TOKEN;
+
+        store.roomFailure({error: fakeError});
+
+        expect(store._storeState.roomState).eql(ROOM_STATES.FAILED);
+        expect(store._storeState.failureReason).eql(FAILURE_REASONS.EXPIRED_OR_INVALID);
+      });
+
+    it("should set the failureReason to EXPIRED_OR_INVALID on server error: " +
+      "expired", function() {
+        fakeError.errno = SERVER_CODES.EXPIRED;
+
+        store.roomFailure({error: fakeError});
+
+        expect(store._storeState.roomState).eql(ROOM_STATES.FAILED);
+        expect(store._storeState.failureReason).eql(FAILURE_REASONS.EXPIRED_OR_INVALID);
+      });
   });
 
   describe("#setupWindowData", function() {
     var fakeToken, fakeRoomData;
 
     beforeEach(function() {
       fakeToken = "337-ff-54";
       fakeRoomData = {
@@ -239,16 +262,24 @@ describe("loop.store.ActiveRoomStore", f
     });
   });
 
   describe("#joinRoom", function() {
     beforeEach(function() {
       store.setStoreState({roomToken: "tokenFake"});
     });
 
+    it("should reset failureReason", function() {
+      store.setStoreState({failureReason: "Test"});
+
+      store.joinRoom();
+
+      expect(store.getStoreState().failureReason).eql(undefined);
+    });
+
     it("should call rooms.join on mozLoop", function() {
       store.joinRoom();
 
       sinon.assert.calledOnce(fakeMozLoop.rooms.join);
       sinon.assert.calledWith(fakeMozLoop.rooms.join, "tokenFake");
     });
 
     it("should dispatch `JoinedRoom` on success", function() {
@@ -375,55 +406,67 @@ describe("loop.store.ActiveRoomStore", f
     it("should set the state to `SESSION_CONNECTED`", function() {
       store.connectedToSdkServers(new sharedActions.ConnectedToSdkServers());
 
       expect(store.getStoreState().roomState).eql(ROOM_STATES.SESSION_CONNECTED);
     });
   });
 
   describe("#connectionFailure", function() {
+    var connectionFailureAction;
+
     beforeEach(function() {
       store.setStoreState({
         roomState: ROOM_STATES.JOINED,
         roomToken: "fakeToken",
         sessionToken: "1627384950"
       });
+
+      connectionFailureAction = new sharedActions.ConnectionFailure({
+        reason: "FAIL"
+      });
+    });
+
+    it("should store the failure reason", function() {
+      store.connectionFailure(connectionFailureAction);
+
+      expect(store.getStoreState().failureReason).eql("FAIL");
     });
 
     it("should reset the multiplexGum", function() {
-      store.leaveRoom();
+      store.connectionFailure(connectionFailureAction);
 
       sinon.assert.calledOnce(fakeMultiplexGum.reset);
     });
 
     it("should disconnect from the servers via the sdk", function() {
-      store.connectionFailure();
+      store.connectionFailure(connectionFailureAction);
 
       sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
     });
 
     it("should clear any existing timeout", function() {
       sandbox.stub(window, "clearTimeout");
       store._timeout = {};
 
-      store.connectionFailure();
+      store.connectionFailure(connectionFailureAction);
 
       sinon.assert.calledOnce(clearTimeout);
     });
 
     it("should call mozLoop.rooms.leave", function() {
-      store.connectionFailure();
+      store.connectionFailure(connectionFailureAction);
 
       sinon.assert.calledOnce(fakeMozLoop.rooms.leave);
       sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave,
         "fakeToken", "1627384950");
     });
 
     it("should set the state to `FAILED`", function() {
-      store.connectionFailure();
+      store.connectionFailure(connectionFailureAction);
 
       expect(store.getStoreState().roomState).eql(ROOM_STATES.FAILED);
     });
   });
 
   describe("#setMute", function() {
     it("should save the mute state for the audio stream", function() {
       store.setStoreState({audioMuted: false});
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -2,26 +2,29 @@
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 var expect = chai.expect;
 
 describe("loop.OTSdkDriver", function () {
   "use strict";
 
   var sharedActions = loop.shared.actions;
-
+  var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
   var sandbox;
   var dispatcher, driver, publisher, sdk, session, sessionData;
-  var fakeLocalElement, fakeRemoteElement, publisherConfig;
+  var fakeLocalElement, fakeRemoteElement, publisherConfig, fakeEvent;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
     fakeLocalElement = {fake: 1};
     fakeRemoteElement = {fake: 2};
+    fakeEvent = {
+      preventDefault: sinon.stub()
+    };
     publisherConfig = {
       fake: "config"
     };
     sessionData = {
       apiKey: "1234567890",
       sessionId: "3216549870",
       sessionToken: "1357924680"
     };
@@ -29,24 +32,24 @@ describe("loop.OTSdkDriver", function ()
     dispatcher = new loop.Dispatcher();
     session = _.extend({
       connect: sinon.stub(),
       disconnect: sinon.stub(),
       publish: sinon.stub(),
       subscribe: sinon.stub()
     }, Backbone.Events);
 
-    publisher = {
+    publisher = _.extend({
       destroy: sinon.stub(),
       publishAudio: sinon.stub(),
       publishVideo: sinon.stub()
-    };
+    }, Backbone.Events);
 
     sdk = {
-      initPublisher: sinon.stub(),
+      initPublisher: sinon.stub().returns(publisher),
       initSession: sinon.stub().returns(session)
     };
 
     driver = new loop.OTSdkDriver({
       dispatcher: dispatcher,
       sdk: sdk
     });
   });
@@ -75,61 +78,16 @@ describe("loop.OTSdkDriver", function ()
         getLocalElementFunc: function() {return fakeLocalElement;},
         getRemoteElementFunc: function() {return fakeRemoteElement;},
         publisherConfig: publisherConfig
       }));
 
       sinon.assert.calledOnce(sdk.initPublisher);
       sinon.assert.calledWith(sdk.initPublisher, fakeLocalElement, publisherConfig);
     });
-
-    describe("On Publisher Complete", function() {
-      it("should publish the stream if the connection is ready", function() {
-        sdk.initPublisher.callsArgWith(2, null);
-
-        driver.session = session;
-        driver._sessionConnected = true;
-
-        dispatcher.dispatch(new sharedActions.SetupStreamElements({
-          getLocalElementFunc: function() {return fakeLocalElement;},
-          getRemoteElementFunc: function() {return fakeRemoteElement;},
-          publisherConfig: publisherConfig
-        }));
-
-        sinon.assert.calledOnce(session.publish);
-      });
-
-      it("should dispatch connectionFailure if connecting failed", function() {
-        sdk.initPublisher.callsArgWith(2, new Error("Failure"));
-
-        // Special stub, as we want to use the dispatcher, but also know that
-        // we've been called correctly for the second dispatch.
-        var dispatchStub = (function() {
-          var originalDispatch = dispatcher.dispatch.bind(dispatcher);
-          return sandbox.stub(dispatcher, "dispatch", function(action) {
-            originalDispatch(action);
-          });
-        }());
-
-        driver.session = session;
-        driver._sessionConnected = true;
-
-        dispatcher.dispatch(new sharedActions.SetupStreamElements({
-          getLocalElementFunc: function() {return fakeLocalElement;},
-          getRemoteElementFunc: function() {return fakeRemoteElement;},
-          publisherConfig: publisherConfig
-        }));
-
-        sinon.assert.called(dispatcher.dispatch);
-        sinon.assert.calledWithMatch(dispatcher.dispatch,
-          sinon.match.hasOwn("name", "connectionFailure"));
-        sinon.assert.calledWithMatch(dispatcher.dispatch,
-          sinon.match.hasOwn("reason", "noMedia"));
-      });
-    });
   });
 
   describe("#setMute", function() {
     beforeEach(function() {
       sdk.initPublisher.returns(publisher);
 
       dispatcher.dispatch(new sharedActions.SetupStreamElements({
         getLocalElementFunc: function() {return fakeLocalElement;},
@@ -189,17 +147,17 @@ describe("loop.OTSdkDriver", function ()
         sandbox.stub(dispatcher, "dispatch");
 
         driver.connectSession(sessionData);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "connectionFailure"));
         sinon.assert.calledWithMatch(dispatcher.dispatch,
-          sinon.match.hasOwn("reason", "couldNotConnect"));
+          sinon.match.hasOwn("reason", FAILURE_REASONS.COULD_NOT_CONNECT));
       });
     });
   });
 
   describe("#disconnectionSession", function() {
     it("should disconnect the session", function() {
       driver.session = session;
 
@@ -264,17 +222,17 @@ describe("loop.OTSdkDriver", function ()
           session.trigger("sessionDisconnected", {
             reason: "networkDisconnected"
           });
 
           sinon.assert.calledOnce(dispatcher.dispatch);
           sinon.assert.calledWithMatch(dispatcher.dispatch,
             sinon.match.hasOwn("name", "connectionFailure"));
           sinon.assert.calledWithMatch(dispatcher.dispatch,
-            sinon.match.hasOwn("reason", "networkDisconnected"));
+            sinon.match.hasOwn("reason", FAILURE_REASONS.NETWORK_DISCONNECTED));
         });
     });
 
     describe("streamCreated", function() {
       var fakeStream;
 
       beforeEach(function() {
         fakeStream = {
@@ -323,10 +281,46 @@ describe("loop.OTSdkDriver", function ()
         function() {
           session.trigger("connectionCreated", {
             connection: {id: "localUser"}
           });
 
           sinon.assert.notCalled(dispatcher.dispatch);
         });
     });
+
+    describe("accessAllowed", function() {
+      it("should publish the stream if the connection is ready", function() {
+        driver._sessionConnected = true;
+
+        publisher.trigger("accessAllowed", fakeEvent);
+
+        sinon.assert.calledOnce(session.publish);
+      });
+    });
+
+    describe("accessDenied", function() {
+      it("should prevent the default event behavior", function() {
+        publisher.trigger("accessDenied", fakeEvent);
+
+        sinon.assert.calledOnce(fakeEvent.preventDefault);
+      });
+
+      it("should dispatch connectionFailure", function() {
+        publisher.trigger("accessDenied", fakeEvent);
+
+        sinon.assert.called(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionFailure"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("reason", FAILURE_REASONS.MEDIA_DENIED));
+      });
+    });
+
+    describe("accessDialogOpened", function() {
+      it("should prevent the default event behavior", function() {
+        publisher.trigger("accessDialogOpened", fakeEvent);
+
+        sinon.assert.calledOnce(fakeEvent.preventDefault);
+      });
+    });
   });
 });
--- a/browser/components/loop/test/standalone/standaloneRoomViews_test.js
+++ b/browser/components/loop/test/standalone/standaloneRoomViews_test.js
@@ -134,16 +134,26 @@ describe("loop.standaloneRoomViews", fun
           function() {
             activeRoomStore.setStoreState({roomState: ROOM_STATES.FULL});
 
             expect(view.getDOMNode().querySelector(".full-room-message"))
               .not.eql(null);
           });
       });
 
+      describe("Failed room message", function() {
+        it("should display a failed room message on FAILED",
+          function() {
+            activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
+
+            expect(view.getDOMNode().querySelector(".failed-room-message"))
+              .not.eql(null);
+          });
+      });
+
       describe("Join button", function() {
         function getJoinButton(view) {
           return view.getDOMNode().querySelector(".btn-join");
         }
 
         it("should render the Join button when room isn't active", function() {
           activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
 
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -603,16 +603,26 @@
             Example({summary: "Standalone room conversation (full - non FFx user)"}, 
               React.DOM.div({className: "standalone"}, 
                 StandaloneRoomView({
                   dispatcher: dispatcher, 
                   activeRoomStore: activeRoomStore, 
                   roomState: ROOM_STATES.FULL, 
                   helper: {isFirefox: returnFalse}})
               )
+            ), 
+
+            Example({summary: "Standalone room conversation (failed)"}, 
+              React.DOM.div({className: "standalone"}, 
+                StandaloneRoomView({
+                  dispatcher: dispatcher, 
+                  activeRoomStore: activeRoomStore, 
+                  roomState: ROOM_STATES.FAILED, 
+                  helper: {isFirefox: returnFalse}})
+              )
             )
           ), 
 
           Section({name: "SVG icons preview"}, 
             Example({summary: "16x16"}, 
               SVGIcons(null)
             )
           )
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -604,16 +604,26 @@
               <div className="standalone">
                 <StandaloneRoomView
                   dispatcher={dispatcher}
                   activeRoomStore={activeRoomStore}
                   roomState={ROOM_STATES.FULL}
                   helper={{isFirefox: returnFalse}} />
               </div>
             </Example>
+
+            <Example summary="Standalone room conversation (failed)">
+              <div className="standalone">
+                <StandaloneRoomView
+                  dispatcher={dispatcher}
+                  activeRoomStore={activeRoomStore}
+                  roomState={ROOM_STATES.FAILED}
+                  helper={{isFirefox: returnFalse}} />
+              </div>
+            </Example>
           </Section>
 
           <Section name="SVG icons preview">
             <Example summary="16x16">
               <SVGIcons />
             </Example>
           </Section>