Bug 1110937 - Make Loop's link-clicker show the expired/invalid failure view before the user clicks join. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Tue, 07 Jul 2015 11:58:18 +0100
changeset 276074 81993d7787280ce9422c4e58013357070950a810
parent 276073 a8e32a9f40ba87f5c6b7dcc1b00fa2a7346503ba
child 276075 ec22db983463c5b52553fda015ed265a8ee8ec37
push id3255
push usermhaigh@mozilla.com
push dateTue, 07 Jul 2015 15:35:48 +0000
reviewersmikedeboer
bugs1110937
milestone42.0a1
Bug 1110937 - Make Loop's link-clicker show the expired/invalid failure view before the user clicks join. r=mikedeboer
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/activeRoomStore.js
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/test/shared/activeRoomStore_test.js
browser/components/loop/test/standalone/index.html
browser/components/loop/test/standalone/standaloneMetricsStore_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/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -496,16 +496,23 @@ loop.shared.actions = (function() {
     /**
      * Starts the process for the user to join the room.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     JoinRoom: Action.define("joinRoom", {
     }),
 
     /**
+     * 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.
      * XXX: should move to some roomActions module - refs bug 1079284
      *
      * @see https://wiki.mozilla.org/Loop/Architecture/Rooms#Joining_a_Room
      */
     JoinedRoom: Action.define("joinedRoom", {
       apiKey: String,
       sessionToken: String,
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -20,17 +20,18 @@ loop.store.ActiveRoomStore = (function()
   var ROOM_STATES = loop.store.ROOM_STATES;
 
   var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
 
   var OPTIONAL_ROOMINFO_FIELDS = {
     urls: "roomContextUrls",
     description: "roomDescription",
     roomInfoFailure: "roomInfoFailure",
-    roomName: "roomName"
+    roomName: "roomName",
+    roomState: "roomState"
   };
 
   /**
    * Active room store.
    *
    * @param {loop.Dispatcher} dispatcher  The dispatcher for dispatching actions
    *                                      and registering to consume actions.
    * @param {Object} options Options object:
@@ -142,32 +143,77 @@ loop.store.ActiveRoomStore = (function()
           default:
             return FAILURE_DETAILS.UNKNOWN;
         }
       }
 
       console.error("Error in state `" + this._storeState.roomState + "`:",
         actionData.error);
 
+      var exitState = this._storeState.roomState !== ROOM_STATES.FAILED ?
+        this._storeState.roomState : this._storeState.failureExitState;
+
       this.setStoreState({
         error: actionData.error,
-        failureReason: getReason(actionData.error.errno)
+        failureReason: getReason(actionData.error.errno),
+        failureExitState: exitState
       });
 
       this._leaveRoom(actionData.error.errno === REST_ERRNOS.ROOM_FULL ?
           ROOM_STATES.FULL : ROOM_STATES.FAILED, actionData.failedJoinRequest);
     },
 
     /**
+     * Attempts to retry getting the room data if there has been a room failure.
+     */
+    retryAfterRoomFailure: function() {
+      if (this._storeState.failureReason === FAILURE_DETAILS.EXPIRED_OR_INVALID) {
+        console.error("Invalid retry attempt for expired or invalid url");
+        return;
+      }
+
+      switch (this._storeState.failureExitState) {
+        case ROOM_STATES.GATHER:
+          this.dispatchAction(new sharedActions.FetchServerData({
+            cryptoKey: this._storeState.roomCryptoKey,
+            token: this._storeState.roomToken,
+            windowType: "room"
+          }));
+          return;
+        case ROOM_STATES.INIT:
+        case ROOM_STATES.ENDED:
+        case ROOM_STATES.CLOSING:
+          console.error("Unexpected retry for exit state", this._storeState.failureExitState);
+          return;
+        default:
+          // For all other states, we simply join the room. We avoid dispatching
+          // another action here so that metrics doesn't get two notifications
+          // in a row (one for retry, one for the join).
+          this.joinRoom();
+          return;
+      }
+    },
+
+    /**
      * Registers the actions with the dispatcher that this store is interested
      * in after the initial setup has been performed.
      */
     _registerPostSetupActions: function() {
+      // Protect against calling this twice, as we don't want to register
+      // before we know what type we are, but in some cases we need to re-do
+      // an action (e.g. FetchServerData).
+      if (this._registeredActions) {
+        return;
+      }
+
+      this._registeredActions = true;
+
       this.dispatcher.register(this, [
         "roomFailure",
+        "retryAfterRoomFailure",
         "setupRoomInfo",
         "updateRoomInfo",
         "gotMediaPermission",
         "joinRoom",
         "joinedRoom",
         "connectedToSdkServers",
         "connectionFailure",
         "setMute",
@@ -251,42 +297,49 @@ loop.store.ActiveRoomStore = (function()
         // Nothing for us to do here, leave it to other stores.
         return;
       }
 
       this._registerPostSetupActions();
 
       this.setStoreState({
         roomToken: actionData.token,
-        roomCryptoKey: actionData.cryptoKey,
-        roomState: ROOM_STATES.READY
+        roomState: ROOM_STATES.GATHER,
+        roomCryptoKey: actionData.cryptoKey
       });
 
       this._mozLoop.rooms.on("update:" + actionData.roomToken,
         this._handleRoomUpdate.bind(this));
       this._mozLoop.rooms.on("delete:" + actionData.roomToken,
         this._handleRoomDelete.bind(this));
 
       this._getRoomDataForStandalone();
     },
 
     _getRoomDataForStandalone: function() {
       this._mozLoop.rooms.get(this._storeState.roomToken, function(err, result) {
         if (err) {
-          // XXX Bug 1110937 will want to handle the error results here
-          // e.g. room expired/invalid.
-          console.error("Failed to get room data:", err);
+          this.dispatchAction(new sharedActions.RoomFailure({
+            error: err,
+            failedJoinRequest: false
+          }));
           return;
         }
 
         var roomInfoData = new sharedActions.UpdateRoomInfo({
           roomOwner: result.roomOwner,
           roomUrl: result.roomUrl
         });
 
+        // 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.
+        roomInfoData.roomState = ROOM_STATES.READY;
+
         if (!result.context && !result.roomName) {
           roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.NO_DATA;
           this.dispatcher.dispatch(roomInfoData);
           return;
         }
 
         // This handles 'legacy', non-encrypted room names.
         if (result.roomName && !result.context) {
@@ -537,21 +590,25 @@ loop.store.ActiveRoomStore = (function()
           this.getStoreState().videoMuted === false) {
         // We failed to publish with media, so due to the bug, we try again without
         // video.
         this.setStoreState({videoMuted: true});
         this._sdkDriver.retryPublishWithoutVideo();
         return;
       }
 
+      var exitState = this._storeState.roomState === ROOM_STATES.FAILED ?
+        this._storeState.failureExitState : this._storeState.roomState;
+
       // 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
+        failureReason: actionData.reason,
+        failureExitState: exitState
       });
 
       this._leaveRoom(ROOM_STATES.FAILED);
     },
 
     /**
      * Records the mute state for the stream.
      *
--- a/browser/components/loop/standalone/content/js/standaloneMetricsStore.js
+++ b/browser/components/loop/standalone/content/js/standaloneMetricsStore.js
@@ -44,17 +44,18 @@ loop.store.StandaloneMetricsStore = (fun
       "connectedToSdkServers",
       "connectionFailure",
       "gotMediaPermission",
       "joinRoom",
       "joinedRoom",
       "leaveRoom",
       "mediaConnected",
       "recordClick",
-      "remotePeerConnected"
+      "remotePeerConnected",
+      "retryAfterRoomFailure"
     ],
 
     /**
      * Initializes the store and starts listening to the activeRoomStore.
      *
      * @param  {Object} options Options for the store, should include a
      *                          reference to the activeRoomStore.
      */
@@ -186,16 +187,25 @@ loop.store.StandaloneMetricsStore = (fun
      * Handles notification that the remote peer is connected.
      */
     remotePeerConnected: function() {
       this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.success,
         "Remote peer connected");
     },
 
     /**
+     * Handles when the user retrys room activity after its failed initially
+     * (e.g. on first load).
+     */
+    retryAfterRoomFailure: function() {
+      this._storeEvent(METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.button,
+        "Retry failed room");
+    },
+
+    /**
      * Handles notifications that the activeRoomStore has changed, updating
      * the metrics for room state and mute state as necessary.
      */
     _onActiveRoomStoreChange: function() {
       var roomStore = this.activeRoomStore.getStoreState();
 
       this._checkRoomState(roomStore.roomState, roomStore.failureReason);
 
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -9,22 +9,85 @@ loop.standaloneRoomViews = (function(moz
   var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedUtils = loop.shared.utils;
   var sharedViews = loop.shared.views;
 
+  /**
+   * 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.
+      failureReason: React.PropTypes.string
+    },
+
+    /**
+     * Handles when the retry button is pressed.
+     */
+    handleRetryButton: function() {
+      this.props.dispatcher.dispatch(new sharedActions.RetryAfterRoomFailure());
+    },
+
+    /**
+     * @return String An appropriate string according to the failureReason.
+     */
+    getFailureString: function() {
+      switch(this.props.failureReason) {
+        case FAILURE_DETAILS.MEDIA_DENIED:
+        // XXX Bug 1166824 should provide a better string for this.
+        case FAILURE_DETAILS.NO_MEDIA:
+          return mozL10n.get("rooms_media_denied_message");
+        case FAILURE_DETAILS.EXPIRED_OR_INVALID:
+          return mozL10n.get("rooms_unavailable_notification_message");
+        default:
+          return mozL10n.get("status_error");
+      }
+    },
+
+    /**
+     * This renders a retry button if one is necessary.
+     */
+    renderRetryButton: function() {
+      if (this.props.failureReason === FAILURE_DETAILS.EXPIRED_OR_INVALID) {
+        return null;
+      }
+
+      return (
+        React.createElement("button", {className: "btn btn-join btn-info", 
+                onClick: this.handleRetryButton}, 
+          mozL10n.get("retry_call_button")
+        )
+      );
+    },
+
+    render: function() {
+      return (
+        React.createElement("div", {className: "room-inner-info-area"}, 
+          React.createElement("p", {className: "failed-room-message"}, 
+            this.getFailureString()
+          ), 
+          this.renderRetryButton()
+        )
+      );
+    }
+  });
+
   var StandaloneRoomInfoArea = React.createClass({displayName: "StandaloneRoomInfoArea",
     propTypes: {
       activeRoomStore: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
         React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
       ]).isRequired,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       failureReason: React.PropTypes.string,
       isFirefox: React.PropTypes.bool.isRequired,
       joinRoom: React.PropTypes.func.isRequired,
       roomState: React.PropTypes.string.isRequired,
       roomUsed: React.PropTypes.bool.isRequired
     },
 
     onFeedbackSent: function() {
@@ -48,35 +111,18 @@ loop.standaloneRoomViews = (function(moz
         React.createElement("a", {className: "btn btn-info", href: loop.config.downloadFirefoxUrl}, 
           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_DETAILS.MEDIA_DENIED:
-        // XXX Bug 1166824 should provide a better string for this.
-        case FAILURE_DETAILS.NO_MEDIA:
-          return mozL10n.get("rooms_media_denied_message");
-        case FAILURE_DETAILS.EXPIRED_OR_INVALID:
-          return mozL10n.get("rooms_unavailable_notification_message");
-        default:
-          return mozL10n.get("status_error");
-      }
-    },
-
     render: function() {
       switch(this.props.roomState) {
-        case ROOM_STATES.INIT:
         case ROOM_STATES.READY: {
           // XXX: In ENDED state, we should rather display the feedback form.
           return (
             React.createElement("div", {className: "room-inner-info-area"}, 
               React.createElement("button", {className: "btn btn-join btn-info", 
                       onClick: this.props.joinRoom}, 
                 mozL10n.get("rooms_room_join_label")
               )
@@ -139,27 +185,23 @@ loop.standaloneRoomViews = (function(moz
 
           // In case the room was not used (no one was here), we
           // bypass the feedback form.
           this.onFeedbackSent();
           return null;
         }
         case ROOM_STATES.FAILED: {
           return (
-            React.createElement("div", {className: "room-inner-info-area"}, 
-              React.createElement("p", {className: "failed-room-message"}, 
-                this._getFailureString()
-              ), 
-              React.createElement("button", {className: "btn btn-join btn-info", 
-                      onClick: this.props.joinRoom}, 
-                mozL10n.get("retry_call_button")
-              )
-            )
+            React.createElement(StandaloneRoomFailureView, {
+              dispatcher: this.props.dispatcher, 
+              failureReason: this.props.failureReason})
           );
         }
+        case ROOM_STATES.INIT:
+        case ROOM_STATES.GATHER:
         default: {
           return null;
         }
       }
     }
   });
 
   var StandaloneRoomHeader = React.createClass({displayName: "StandaloneRoomHeader",
@@ -357,16 +399,17 @@ loop.standaloneRoomViews = (function(moz
         case ROOM_STATES.JOINING:
         case ROOM_STATES.SESSION_CONNECTED:
         case ROOM_STATES.JOINED:
         case ROOM_STATES.MEDIA_WAIT:
           // this case is so that we don't show an avatar while waiting for
           // the other party to connect
           return true;
 
+        case ROOM_STATES.FAILED:
         case ROOM_STATES.CLOSING:
           // the other person has shown up, so we don't want to show an avatar
           return true;
 
         default:
           console.warn("StandaloneRoomView.shouldRenderRemoteVideo:" +
             " unexpected roomState: ", this.state.roomState);
           return true;
@@ -432,16 +475,17 @@ loop.standaloneRoomViews = (function(moz
           this.props.localPosterUrl
       });
 
       return (
         React.createElement("div", {className: "room-conversation-wrapper"}, 
           React.createElement("div", {className: "beta-logo"}), 
           React.createElement(StandaloneRoomHeader, {dispatcher: this.props.dispatcher}), 
           React.createElement(StandaloneRoomInfoArea, {activeRoomStore: this.props.activeRoomStore, 
+                                  dispatcher: this.props.dispatcher, 
                                   failureReason: this.state.failureReason, 
                                   isFirefox: this.props.isFirefox, 
                                   joinRoom: this.joinRoom, 
                                   roomState: this.state.roomState, 
                                   roomUsed: this.state.used}), 
           React.createElement("div", {className: "media-layout"}, 
             React.createElement("div", {className: mediaWrapperClasses}, 
               React.createElement("span", {className: "self-view-hidden-message"}, 
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -9,22 +9,85 @@ loop.standaloneRoomViews = (function(moz
   var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedUtils = loop.shared.utils;
   var sharedViews = loop.shared.views;
 
+  /**
+   * 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.
+      failureReason: React.PropTypes.string
+    },
+
+    /**
+     * Handles when the retry button is pressed.
+     */
+    handleRetryButton: function() {
+      this.props.dispatcher.dispatch(new sharedActions.RetryAfterRoomFailure());
+    },
+
+    /**
+     * @return String An appropriate string according to the failureReason.
+     */
+    getFailureString: function() {
+      switch(this.props.failureReason) {
+        case FAILURE_DETAILS.MEDIA_DENIED:
+        // XXX Bug 1166824 should provide a better string for this.
+        case FAILURE_DETAILS.NO_MEDIA:
+          return mozL10n.get("rooms_media_denied_message");
+        case FAILURE_DETAILS.EXPIRED_OR_INVALID:
+          return mozL10n.get("rooms_unavailable_notification_message");
+        default:
+          return mozL10n.get("status_error");
+      }
+    },
+
+    /**
+     * This renders a retry button if one is necessary.
+     */
+    renderRetryButton: function() {
+      if (this.props.failureReason === FAILURE_DETAILS.EXPIRED_OR_INVALID) {
+        return null;
+      }
+
+      return (
+        <button className="btn btn-join btn-info"
+                onClick={this.handleRetryButton}>
+          {mozL10n.get("retry_call_button")}
+        </button>
+      );
+    },
+
+    render: function() {
+      return (
+        <div className="room-inner-info-area">
+          <p className="failed-room-message">
+            {this.getFailureString()}
+          </p>
+          {this.renderRetryButton()}
+        </div>
+      );
+    }
+  });
+
   var StandaloneRoomInfoArea = React.createClass({
     propTypes: {
       activeRoomStore: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
         React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
       ]).isRequired,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       failureReason: React.PropTypes.string,
       isFirefox: React.PropTypes.bool.isRequired,
       joinRoom: React.PropTypes.func.isRequired,
       roomState: React.PropTypes.string.isRequired,
       roomUsed: React.PropTypes.bool.isRequired
     },
 
     onFeedbackSent: function() {
@@ -48,35 +111,18 @@ loop.standaloneRoomViews = (function(moz
         <a className="btn btn-info" href={loop.config.downloadFirefoxUrl}>
           {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_DETAILS.MEDIA_DENIED:
-        // XXX Bug 1166824 should provide a better string for this.
-        case FAILURE_DETAILS.NO_MEDIA:
-          return mozL10n.get("rooms_media_denied_message");
-        case FAILURE_DETAILS.EXPIRED_OR_INVALID:
-          return mozL10n.get("rooms_unavailable_notification_message");
-        default:
-          return mozL10n.get("status_error");
-      }
-    },
-
     render: function() {
       switch(this.props.roomState) {
-        case ROOM_STATES.INIT:
         case ROOM_STATES.READY: {
           // XXX: In ENDED state, we should rather display the feedback form.
           return (
             <div className="room-inner-info-area">
               <button className="btn btn-join btn-info"
                       onClick={this.props.joinRoom}>
                 {mozL10n.get("rooms_room_join_label")}
               </button>
@@ -139,27 +185,23 @@ loop.standaloneRoomViews = (function(moz
 
           // In case the room was not used (no one was here), we
           // bypass the feedback form.
           this.onFeedbackSent();
           return null;
         }
         case ROOM_STATES.FAILED: {
           return (
-            <div className="room-inner-info-area">
-              <p className="failed-room-message">
-                {this._getFailureString()}
-              </p>
-              <button className="btn btn-join btn-info"
-                      onClick={this.props.joinRoom}>
-                {mozL10n.get("retry_call_button")}
-              </button>
-            </div>
+            <StandaloneRoomFailureView
+              dispatcher={this.props.dispatcher}
+              failureReason={this.props.failureReason} />
           );
         }
+        case ROOM_STATES.INIT:
+        case ROOM_STATES.GATHER:
         default: {
           return null;
         }
       }
     }
   });
 
   var StandaloneRoomHeader = React.createClass({
@@ -357,16 +399,17 @@ loop.standaloneRoomViews = (function(moz
         case ROOM_STATES.JOINING:
         case ROOM_STATES.SESSION_CONNECTED:
         case ROOM_STATES.JOINED:
         case ROOM_STATES.MEDIA_WAIT:
           // this case is so that we don't show an avatar while waiting for
           // the other party to connect
           return true;
 
+        case ROOM_STATES.FAILED:
         case ROOM_STATES.CLOSING:
           // the other person has shown up, so we don't want to show an avatar
           return true;
 
         default:
           console.warn("StandaloneRoomView.shouldRenderRemoteVideo:" +
             " unexpected roomState: ", this.state.roomState);
           return true;
@@ -432,16 +475,17 @@ loop.standaloneRoomViews = (function(moz
           this.props.localPosterUrl
       });
 
       return (
         <div className="room-conversation-wrapper">
           <div className="beta-logo" />
           <StandaloneRoomHeader dispatcher={this.props.dispatcher} />
           <StandaloneRoomInfoArea activeRoomStore={this.props.activeRoomStore}
+                                  dispatcher={this.props.dispatcher}
                                   failureReason={this.state.failureReason}
                                   isFirefox={this.props.isFirefox}
                                   joinRoom={this.joinRoom}
                                   roomState={this.state.roomState}
                                   roomUsed={this.state.used} />
           <div className="media-layout">
             <div className={mediaWrapperClasses}>
               <span className="self-view-hidden-message">
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -228,16 +228,81 @@ describe("loop.store.ActiveRoomStore", f
         error: fakeError,
         failedJoinRequest: true
       }));
 
       sinon.assert.notCalled(fakeMozLoop.rooms.leave);
     });
   });
 
+  describe("#retryAfterRoomFailure", function() {
+    beforeEach(function() {
+      sandbox.stub(console, "error");
+    });
+
+    it("should reject attempts to retry for invalid/expired urls", function() {
+      store.setStoreState({
+        failureReason: FAILURE_DETAILS.EXPIRED_OR_INVALID
+      });
+
+      store.retryAfterRoomFailure();
+
+      sinon.assert.calledOnce(console.error);
+      sinon.assert.calledWithMatch(console.error, "Invalid");
+      sinon.assert.notCalled(dispatcher.dispatch);
+    });
+
+    it("should reject attempts if the failure exit state is not expected", function() {
+      store.setStoreState({
+        failureReason: FAILURE_DETAILS.UNKNOWN,
+        failureExitState: ROOM_STATES.INIT
+      });
+
+      store.retryAfterRoomFailure();
+
+      sinon.assert.calledOnce(console.error);
+      sinon.assert.calledWithMatch(console.error, "Unexpected");
+      sinon.assert.notCalled(dispatcher.dispatch);
+    });
+
+    it("should dispatch a FetchServerData action when the exit state is GATHER", function() {
+      store.setStoreState({
+        failureReason: FAILURE_DETAILS.UNKNOWN,
+        failureExitState: ROOM_STATES.GATHER,
+        roomCryptoKey: "fakeKey",
+        roomToken: "fakeToken"
+      });
+
+      store.retryAfterRoomFailure();
+
+      sinon.assert.calledOnce(dispatcher.dispatch);
+      sinon.assert.calledWithExactly(dispatcher.dispatch,
+        new sharedActions.FetchServerData({
+          cryptoKey: "fakeKey",
+          token: "fakeToken",
+          windowType: "room"
+        }));
+    });
+
+    it("should join the room for other states", function() {
+      sandbox.stub(store, "joinRoom");
+
+      store.setStoreState({
+        failureReason: FAILURE_DETAILS.UNKNOWN,
+        failureExitState: ROOM_STATES.MEDIA_WAIT,
+        roomCryptoKey: "fakeKey",
+        roomToken: "fakeToken"
+      });
+
+      store.retryAfterRoomFailure();
+
+      sinon.assert.calledOnce(store.joinRoom);
+    });
+  });
+
   describe("#setupWindowData", function() {
     var fakeToken, fakeRoomData;
 
     beforeEach(function() {
       fakeToken = "337-ff-54";
       fakeRoomData = {
         decryptedContext: {
           roomName: "Monkeys"
@@ -341,20 +406,20 @@ describe("loop.store.ActiveRoomStore", f
     });
 
     it("should save the token", function() {
       store.fetchServerData(fetchServerAction);
 
       expect(store.getStoreState().roomToken).eql("fakeToken");
     });
 
-    it("should set the state to `READY`", function() {
+    it("should set the state to `GATHER`", function() {
       store.fetchServerData(fetchServerAction);
 
-      expect(store.getStoreState().roomState).eql(ROOM_STATES.READY);
+      expect(store.getStoreState().roomState).eql(ROOM_STATES.GATHER);
     });
 
     it("should call mozLoop.rooms.get to get the room data", function() {
       store.fetchServerData(fetchServerAction);
 
       sinon.assert.calledOnce(fakeMozLoop.rooms.get);
     });
 
@@ -366,16 +431,17 @@ describe("loop.store.ActiveRoomStore", f
 
       store.fetchServerData(fetchServerAction);
 
       sinon.assert.calledOnce(dispatcher.dispatch);
       sinon.assert.calledWithExactly(dispatcher.dispatch,
         new sharedActions.UpdateRoomInfo({
           roomInfoFailure: ROOM_INFO_FAILURES.NO_DATA,
           roomOwner: "Dan",
+          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",
@@ -384,17 +450,19 @@ describe("loop.store.ActiveRoomStore", f
         };
 
         fakeMozLoop.rooms.get.callsArgWith(1, null, roomDetails);
 
         store.fetchServerData(fetchServerAction);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
-          new sharedActions.UpdateRoomInfo(roomDetails));
+          new sharedActions.UpdateRoomInfo(_.extend({
+            roomState: ROOM_STATES.READY
+          }, roomDetails)));
       });
     });
 
     describe("mozLoop.rooms.get returns encryptedContext", function() {
       var roomDetails, expectedDetails;
 
       beforeEach(function() {
         roomDetails = {
@@ -417,27 +485,29 @@ describe("loop.store.ActiveRoomStore", f
       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
+            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
+            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.
@@ -449,17 +519,18 @@ describe("loop.store.ActiveRoomStore", f
           };
         });
 
         store.fetchServerData(fetchServerAction);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.UpdateRoomInfo(_.extend({
-            roomInfoFailure: ROOM_INFO_FAILURES.DECRYPT_FAILED
+            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...",
@@ -478,19 +549,23 @@ describe("loop.store.ActiveRoomStore", f
             then: function(resolve, reject) {
               resolve(JSON.stringify(roomContext));
             }
           };
         });
 
         store.fetchServerData(fetchServerAction);
 
+        var expectedData = _.extend({
+          roomState: ROOM_STATES.READY
+        }, roomContext, expectedDetails);
+
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
-          new sharedActions.UpdateRoomInfo(_.extend(roomContext, expectedDetails)));
+          new sharedActions.UpdateRoomInfo(expectedData));
       });
     });
   });
 
   describe("#feedbackComplete", function() {
     it("should set the room state to READY", function() {
       store.setStoreState({
         roomState: ROOM_STATES.ENDED,
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -83,17 +83,17 @@
     describe("Uncaught Error Check", function() {
       it("should load the tests without errors", function() {
         chai.expect(uncaughtError && uncaughtError.message).to.be.undefined;
       });
     });
 
     describe("Unexpected Warnings Check", function() {
       it("should long only the warnings we expect", function() {
-        chai.expect(caughtWarnings.length).to.eql(36);
+        chai.expect(caughtWarnings.length).to.eql(33);
       });
     });
 
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
 </script>
 </body>
--- a/browser/components/loop/test/standalone/standaloneMetricsStore_test.js
+++ b/browser/components/loop/test/standalone/standaloneMetricsStore_test.js
@@ -136,16 +136,25 @@ describe("loop.store.StandaloneMetricsSt
     it("should log an event on RemotePeerConnected", function() {
       store.remotePeerConnected();
 
       sinon.assert.calledOnce(window.ga);
       sinon.assert.calledWithExactly(window.ga,
         "send", "event", METRICS_GA_CATEGORY.general, METRICS_GA_ACTIONS.success,
         "Remote peer connected");
     });
+
+    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("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
@@ -5,16 +5,17 @@
 describe("loop.standaloneRoomViews", function() {
   "use strict";
 
   var expect = chai.expect;
   var TestUtils = React.addons.TestUtils;
 
   var ROOM_STATES = loop.store.ROOM_STATES;
   var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
+  var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
   var sharedActions = loop.shared.actions;
   var sharedUtils = loop.shared.utils;
 
   var sandbox, dispatcher, activeRoomStore, feedbackStore, dispatch;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
@@ -190,31 +191,46 @@ describe("loop.standaloneRoomViews", fun
             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});
+        beforeEach(function() {
+          activeRoomStore.setStoreState({ roomState: ROOM_STATES.FAILED });
+        });
 
-            expect(view.getDOMNode().querySelector(".failed-room-message"))
-              .not.eql(null);
+        it("should display a failed room message on FAILED", function() {
+          expect(view.getDOMNode().querySelector(".failed-room-message"))
+            .not.eql(null);
+        });
+
+        it("should display a retry button", function() {
+          expect(view.getDOMNode().querySelector(".btn-info")).not.eql(null);
+        });
+
+        it("should not display a retry button when the failure reason is expired or invalid", function() {
+          activeRoomStore.setStoreState({
+            failureReason: FAILURE_DETAILS.EXPIRED_OR_INVALID
           });
 
-        it("should display a retry button",
-          function() {
-            activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
+          expect(view.getDOMNode().querySelector(".btn-info")).eql(null);
+        });
+
+        it("should dispatch a RetryAfterRoomFailure action when the retry button is pressed", function() {
+          var button = view.getDOMNode().querySelector(".btn-info");
 
-            expect(view.getDOMNode().querySelector(".btn-info"))
-              .not.eql(null);
-          });
+          TestUtils.Simulate.click(button);
+
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.RetryAfterRoomFailure());
+        });
       });
 
       describe("Join button", function() {
         function getJoinButton(elem) {
           return elem.getDOMNode().querySelector(".btn-join");
         }
 
         it("should render the Join button when room isn't active", function() {
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -1308,17 +1308,17 @@
         setTimeout(waitForQueuedFrames, 500);
         return;
       }
       // Put the title back, in case views changed it.
       document.title = "Loop UI Components Showcase";
 
       // This simulates the mocha layout for errors which means we can run
       // this alongside our other unit tests but use the same harness.
-      var expectedWarningsCount = 29;
+      var expectedWarningsCount = 28;
       var warningsMismatch = caughtWarnings.length !== expectedWarningsCount;
       if (uncaughtError || warningsMismatch) {
         $("#results").append("<div class='failures'><em>" +
           (!!(uncaughtError && warningsMismatch) ? 2 : 1) + "</em></div>");
         if (warningsMismatch) {
           $("#results").append("<li class='test fail'>" +
             "<h2>Unexpected number of warnings detected in UI-Showcase</h2>" +
             "<pre class='error'>Got: " + caughtWarnings.length + "\n" +
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -1308,17 +1308,17 @@
         setTimeout(waitForQueuedFrames, 500);
         return;
       }
       // Put the title back, in case views changed it.
       document.title = "Loop UI Components Showcase";
 
       // This simulates the mocha layout for errors which means we can run
       // this alongside our other unit tests but use the same harness.
-      var expectedWarningsCount = 29;
+      var expectedWarningsCount = 28;
       var warningsMismatch = caughtWarnings.length !== expectedWarningsCount;
       if (uncaughtError || warningsMismatch) {
         $("#results").append("<div class='failures'><em>" +
           (!!(uncaughtError && warningsMismatch) ? 2 : 1) + "</em></div>");
         if (warningsMismatch) {
           $("#results").append("<li class='test fail'>" +
             "<h2>Unexpected number of warnings detected in UI-Showcase</h2>" +
             "<pre class='error'>Got: " + caughtWarnings.length + "\n" +