Bug 1097742 - Part 1 Handle access being denied to media, and prevent the sdk prompts from showing in Loop Rooms. r=nperriault
authorMark Banner <standard8@mozilla.com>
Thu, 13 Nov 2014 22:45:23 +0000
changeset 215730 bd6aae177d9c470dbdc219ee78a9c7f06b12ac1d
parent 215729 100301b84f5f15a794a2f5c50b87e84e2079728d
child 215731 05323e55599dde491a14c9e87dc9f7f9e415f689
push id27824
push usercbook@mozilla.com
push dateFri, 14 Nov 2014 12:19:28 +0000
treeherdermozilla-central@64206634959a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnperriault
bugs1097742
milestone36.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1097742 - Part 1 Handle access being denied to media, and prevent the sdk prompts from showing in Loop Rooms. r=nperriault
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
@@ -111,16 +111,18 @@ rooms_new_room_button_label=Start a conv
 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.
 
 ## LOCALIZATION_NOTE(standalone_title_with_status): {{clientShortname}} will be
 ## replaced by the brand name and {{currentStatus}} will be replaced
 ## by the current call status (Connecting, Ringing, etc.)
 standalone_title_with_status={{clientShortname}} — {{currentStatus}}
 status_in_conversation=In conversation
 status_conversation_ended=Conversation ended
 status_error=Something went wrong
--- 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>