Bug 1074688 - Part 3 Hook the new activeRoomStore into the standalone views, and also extend the store to manage joining rooms on the Loop server. r=nperriault a=loop-only
authorMark Banner <standard8@mozilla.com>
Thu, 06 Nov 2014 20:53:49 +0000
changeset 233804 9216940023374187351e3594b0855c98ca8e1d20
parent 233803 259292d137124cf8a03efd2012318372d72f7d71
child 233805 dbf5852b8fd35f8aaa571ef436c662876c637d40
push id1
push usersledru@mozilla.com
push dateThu, 04 Dec 2014 17:57:20 +0000
reviewersnperriault, loop-only
bugs1074688
milestone35.0a2
Bug 1074688 - Part 3 Hook the new activeRoomStore into the standalone views, and also extend the store to manage joining rooms on the Loop server. r=nperriault a=loop-only
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/roomViews.js
browser/components/loop/content/js/roomViews.jsx
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/content/shared/js/roomStore.js
browser/components/loop/standalone/content/index.html
browser/components/loop/standalone/content/js/standaloneRoomViews.js
browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/test/desktop-local/roomViews_test.js
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/standalone/index.html
browser/components/loop/test/standalone/webapp_test.js
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -666,17 +666,21 @@ loop.conversation = (function(mozL10n) {
     if (hash) {
       windowId = hash[1];
     }
 
     conversation.set({windowId: windowId});
 
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
+      // XXX Move to the conversation models, when we transition
+      // incoming calls to flux (bug 1088672).
       navigator.mozLoop.calls.clearCallInProgress(windowId);
+
+      dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(AppControllerView({
       conversationAppStore: conversationAppStore, 
       roomStore: roomStore, 
       conversationStore: conversationStore, 
       client: client, 
       conversation: conversation, 
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -666,17 +666,21 @@ loop.conversation = (function(mozL10n) {
     if (hash) {
       windowId = hash[1];
     }
 
     conversation.set({windowId: windowId});
 
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
+      // XXX Move to the conversation models, when we transition
+      // incoming calls to flux (bug 1088672).
       navigator.mozLoop.calls.clearCallInProgress(windowId);
+
+      dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(<AppControllerView
       conversationAppStore={conversationAppStore}
       roomStore={roomStore}
       conversationStore={conversationStore}
       client={client}
       conversation={conversation}
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -5,26 +5,28 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.roomViews = (function(mozL10n) {
   "use strict";
 
+  var ROOM_STATES = loop.store.ROOM_STATES;
+
   var DesktopRoomView = React.createClass({displayName: 'DesktopRoomView',
     mixins: [Backbone.Events, loop.shared.mixins.DocumentTitleMixin],
 
     propTypes: {
       mozLoop:   React.PropTypes.object.isRequired,
       roomStore: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired,
     },
 
     getInitialState: function() {
-      return this.props.roomStore.getStoreState();
+      return this.props.roomStore.getStoreState("activeRoom");
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.roomStore, "change:activeRoom",
                     this._onActiveRoomStateChanged);
     },
 
     /**
@@ -36,23 +38,38 @@ loop.roomViews = (function(mozL10n) {
     _onActiveRoomStateChanged: function() {
       this.setState(this.props.roomStore.getStoreState("activeRoom"));
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.roomStore);
     },
 
+    /**
+     * Closes the window if the cancel button is pressed in the generic failure view.
+     */
+    closeWindow: function() {
+      window.close();
+    },
+
     render: function() {
-      if (this.state.serverData && this.state.serverData.roomName) {
-        this.setTitle(this.state.serverData.roomName);
+      if (this.state.roomName) {
+        this.setTitle(this.state.roomName);
+      }
+
+      if (this.state.roomState === ROOM_STATES.FAILED) {
+        return (loop.conversation.GenericFailureView({
+          cancelCall: this.closeWindow}
+        ));
       }
 
       return (
-        React.DOM.div({className: "goat"})
+        React.DOM.div(null, 
+          React.DOM.div(null, mozL10n.get("invite_header_text"))
+        )
       );
     }
   });
 
   return {
     DesktopRoomView: DesktopRoomView
   };
 
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -5,26 +5,28 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.roomViews = (function(mozL10n) {
   "use strict";
 
+  var ROOM_STATES = loop.store.ROOM_STATES;
+
   var DesktopRoomView = React.createClass({
     mixins: [Backbone.Events, loop.shared.mixins.DocumentTitleMixin],
 
     propTypes: {
       mozLoop:   React.PropTypes.object.isRequired,
       roomStore: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired,
     },
 
     getInitialState: function() {
-      return this.props.roomStore.getStoreState();
+      return this.props.roomStore.getStoreState("activeRoom");
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.roomStore, "change:activeRoom",
                     this._onActiveRoomStateChanged);
     },
 
     /**
@@ -36,23 +38,38 @@ loop.roomViews = (function(mozL10n) {
     _onActiveRoomStateChanged: function() {
       this.setState(this.props.roomStore.getStoreState("activeRoom"));
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.roomStore);
     },
 
+    /**
+     * Closes the window if the cancel button is pressed in the generic failure view.
+     */
+    closeWindow: function() {
+      window.close();
+    },
+
     render: function() {
-      if (this.state.serverData && this.state.serverData.roomName) {
-        this.setTitle(this.state.serverData.roomName);
+      if (this.state.roomName) {
+        this.setTitle(this.state.roomName);
+      }
+
+      if (this.state.roomState === ROOM_STATES.FAILED) {
+        return (<loop.conversation.GenericFailureView
+          cancelCall={this.closeWindow}
+        />);
       }
 
       return (
-        <div className="goat"/>
+        <div>
+          <div>{mozL10n.get("invite_header_text")}</div>
+        </div>
       );
     }
   });
 
   return {
     DesktopRoomView: DesktopRoomView
   };
 
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -63,16 +63,22 @@ loop.shared.actions = (function() {
      * token.
      */
     FetchServerData: Action.define("fetchServerData", {
       token: String,
       windowType: String
     }),
 
     /**
+     * Used to signal when the window is being unloaded.
+     */
+    WindowUnload: Action.define("windowUnload", {
+    }),
+
+    /**
      * Fetch a new call url from the server, intended to be sent over email when
      * a contact can't be reached.
      */
     FetchEmailLink: Action.define("fetchEmailLink", {
     }),
 
     /**
      * Used to cancel call setup.
@@ -222,11 +228,51 @@ loop.shared.actions = (function() {
     }),
 
     /**
      * Copy a room url in the user's clipboard.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     CopyRoomUrl: Action.define("copyRoomUrl", {
       roomUrl: String
+    }),
+
+    /**
+     * XXX: should move to some roomActions module - refs bug 1079284
+     */
+    RoomFailure: Action.define("roomFailure", {
+      error: Object
+    }),
+
+    /**
+     * Updates the room information when it is received.
+     * XXX: should move to some roomActions module - refs bug 1079284
+     *
+     * @see https://wiki.mozilla.org/Loop/Architecture/Rooms#GET_.2Frooms.2F.7Btoken.7D
+     */
+    UpdateRoomInfo: Action.define("updateRoomInfo", {
+      roomName: String,
+      roomOwner: String,
+      roomToken: String,
+      roomUrl: String
+    }),
+
+    /**
+     * Starts the process for the user to join the room.
+     * XXX: should move to some roomActions module - refs bug 1079284
+     */
+    JoinRoom: Action.define("joinRoom", {
+    }),
+
+    /**
+     * 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,
+      sessionId: String,
+      expires: Number
     })
   };
 })();
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -6,16 +6,29 @@
 
 var loop = loop || {};
 loop.store = loop.store || {};
 loop.store.ActiveRoomStore = (function() {
   "use strict";
 
   var sharedActions = loop.shared.actions;
 
+  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",
+    // The room is known to be joined on the loop-server
+    JOINED: "room-joined",
+    // There was an issue with the room
+    FAILED: "room-failed"
+  };
+
   /**
    * Store for things that are local to this instance (in this profile, on
    * this machine) of this roomRoom store, in addition to a mirror of some
    * remote-state.
    *
    * @extends {Backbone.Events}
    *
    * @param {Object}          options - Options object
@@ -24,79 +37,234 @@ loop.store.ActiveRoomStore = (function()
    * @param {MozLoop}         options.mozLoop - MozLoop API provider object
    */
   function ActiveRoomStore(options) {
     options = options || {};
 
     if (!options.dispatcher) {
       throw new Error("Missing option dispatcher");
     }
-    this.dispatcher = options.dispatcher;
+    this._dispatcher = options.dispatcher;
 
     if (!options.mozLoop) {
       throw new Error("Missing option mozLoop");
     }
-    this.mozLoop = options.mozLoop;
+    this._mozLoop = options.mozLoop;
 
-    this.dispatcher.register(this, [
-      "setupWindowData"
+    this._dispatcher.register(this, [
+      "roomFailure",
+      "setupWindowData",
+      "updateRoomInfo",
+      "joinRoom",
+      "joinedRoom",
+      "windowUnload"
     ]);
-  }
-
-  ActiveRoomStore.prototype = _.extend({
 
     /**
      * Stored data reflecting the local state of a given room, used to drive
      * the room's views.
      *
-     * @property {Object} serverData - local cache of the data returned by
-     *                                 MozLoop.getRoomData for this room.
      * @see https://wiki.mozilla.org/Loop/Architecture/Rooms#GET_.2Frooms.2F.7Btoken.7D
+     *      for the main data. Additional properties below.
      *
+     * @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.
      */
-    _storeState: {
-    },
+    this._storeState = {
+      roomState: ROOM_STATES.INIT
+    };
+  }
+
+  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%.
+     */
+    expiresTimeFactor: 0.9,
 
     getStoreState: function() {
       return this._storeState;
     },
 
-    setStoreState: function(state) {
-      this._storeState = state;
+    setStoreState: function(newState) {
+      for (var key in newState) {
+        this._storeState[key] = newState[key];
+      }
       this.trigger("change");
     },
 
     /**
-     * Execute setupWindowData event action from the dispatcher.  This primes
-     * the store with the roomToken, and calls MozLoop.getRoomData on that
-     * ID.  This will return either a reflection of state on the server, or,
-     * if the createRoom call hasn't yet returned, it will have at least the
-     * roomName as specified to the createRoom method.
+     * Handles a room failure. Currently this prints the error to the console
+     * and sets the roomState to failed.
      *
-     * When the room name gets set, that will trigger the view to display
-     * that name.
+     * @param {sharedActions.RoomFailure} actionData
+     */
+    roomFailure: function(actionData) {
+      console.error("Error in state `" + this._storeState.roomState + "`:",
+        actionData.error);
+
+      this.setStoreState({
+        error: actionData.error,
+        roomState: ROOM_STATES.FAILED
+      });
+    },
+
+    /**
+     * Execute setupWindowData event action from the dispatcher. This gets
+     * the room data from the mozLoop api, and dispatches an UpdateRoomInfo event.
+     * It also dispatches JoinRoom as this action is only applicable to the desktop
+     * client, and needs to auto-join.
      *
      * @param {sharedActions.SetupWindowData} actionData
      */
     setupWindowData: function(actionData) {
       if (actionData.type !== "room") {
         // Nothing for us to do here, leave it to other stores.
         return;
       }
 
-      this.mozLoop.rooms.get(actionData.roomToken,
+      this.setStoreState({
+        roomState: ROOM_STATES.GATHER
+      });
+
+      // Get the window data from the mozLoop api.
+      this._mozLoop.rooms.get(actionData.roomToken,
         function(error, roomData) {
-          this.setStoreState({
-            error: error,
+          if (error) {
+            this._dispatcher.dispatch(new sharedActions.RoomFailure({
+              error: error
+            }));
+            return;
+          }
+
+          this._dispatcher.dispatch(
+            new sharedActions.UpdateRoomInfo({
             roomToken: actionData.roomToken,
-            serverData: roomData
-          });
+            roomName: roomData.roomName,
+            roomOwner: roomData.roomOwner,
+            roomUrl: roomData.roomUrl
+          }));
+
+          // For the conversation window, we need to automatically
+          // join the room.
+          this._dispatcher.dispatch(new sharedActions.JoinRoom());
+        }.bind(this));
+    },
+
+    /**
+     * Handles the updateRoomInfo action. Updates the room data and
+     * sets the state to `READY`.
+     *
+     * @param {sharedActions.UpdateRoomInfo} actionData
+     */
+    updateRoomInfo: function(actionData) {
+      this.setStoreState({
+        roomName: actionData.roomName,
+        roomOwner: actionData.roomOwner,
+        roomState: ROOM_STATES.READY,
+        roomToken: actionData.roomToken,
+        roomUrl: actionData.roomUrl
+      });
+    },
+
+    /**
+     * Handles the action to join to a room.
+     */
+    joinRoom: function() {
+      this._mozLoop.rooms.join(this._storeState.roomToken,
+        function(error, responseData) {
+          if (error) {
+            this._dispatcher.dispatch(
+              new sharedActions.RoomFailure({error: error}));
+            return;
+          }
+
+          this._dispatcher.dispatch(new sharedActions.JoinedRoom({
+            apiKey: responseData.apiKey,
+            sessionToken: responseData.sessionToken,
+            sessionId: responseData.sessionId,
+            expires: responseData.expires
+          }));
         }.bind(this));
+    },
+
+    /**
+     * Handles the data received from joining a room. It stores the relevant
+     * data, and sets up the refresh timeout for ensuring membership of the room
+     * is refreshed regularly.
+     *
+     * @param {sharedActions.JoinedRoom} actionData
+     */
+    joinedRoom: function(actionData) {
+      this.setStoreState({
+        apiKey: actionData.apiKey,
+        sessionToken: actionData.sessionToken,
+        sessionId: actionData.sessionId,
+        roomState: ROOM_STATES.JOINED
+      });
+
+      this._setRefreshTimeout(actionData.expires);
+    },
+
+    /**
+     * Handles the window being unloaded. Ensures the room is left.
+     */
+    windowUnload: function() {
+      this._leaveRoom();
+    },
+
+    /**
+     * Handles setting of the refresh timeout callback.
+     *
+     * @param {Integer} expireTime The time until expiry (in seconds).
+     */
+    _setRefreshTimeout: function(expireTime) {
+      this._timeout = setTimeout(this._refreshMembership.bind(this),
+        expireTime * this.expiresTimeFactor * 1000);
+    },
+
+    /**
+     * Refreshes the membership of the room with the server, and then
+     * sets up the refresh for the next cycle.
+     */
+    _refreshMembership: function() {
+      this._mozLoop.rooms.refreshMembership(this._storeState.roomToken,
+        this._storeState.sessionToken,
+        function(error, responseData) {
+          if (error) {
+            this._dispatcher.dispatch(
+              new sharedActions.RoomFailure({error: error}));
+            return;
+          }
+
+          this._setRefreshTimeout(responseData.expires);
+        }.bind(this));
+    },
+
+    /**
+     * Handles leaving a room. Clears any membership timeouts, then
+     * signals to the server the leave of the room.
+     */
+    _leaveRoom: function() {
+      if (this._storeState.roomState !== ROOM_STATES.JOINED) {
+        return;
+      }
+
+      if (this._timeout) {
+        clearTimeout(this._timeout);
+        delete this._timeout;
+      }
+
+      this._mozLoop.rooms.leave(this._storeState.roomToken,
+        this._storeState.sessionToken);
+
+      this.setStoreState({
+        roomState: ROOM_STATES.READY
+      });
     }
 
   }, Backbone.Events);
 
   return ActiveRoomStore;
 
 })();
--- a/browser/components/loop/content/shared/js/roomStore.js
+++ b/browser/components/loop/content/shared/js/roomStore.js
@@ -94,20 +94,20 @@ loop.store = loop.store || {};
      * Maximum size given to createRoom; only 2 is supported (and is
      * always passed) because that's what the user-experience is currently
      * designed and tested to handle.
      * @type {Number}
      */
     maxRoomCreationSize: 2,
 
     /**
-     * The number of hours for which the room will exist.
+     * The number of hours for which the room will exist - default 8 weeks
      * @type {Number}
      */
-    defaultExpiresIn: 5,
+    defaultExpiresIn: 24 * 7 * 8,
 
     /**
      * Internal store state representation.
      * @type {Object}
      * @see  #getStoreState
      */
     _storeState: {
       activeRoom: {},
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -41,16 +41,17 @@
     <script type="text/javascript" src="shared/js/models.js"></script>
     <script type="text/javascript" src="shared/js/mixins.js"></script>
     <script type="text/javascript" src="shared/js/views.js"></script>
     <script type="text/javascript" src="shared/js/feedbackApiClient.js"></script>
     <script type="text/javascript" src="shared/js/actions.js"></script>
     <script type="text/javascript" src="shared/js/validate.js"></script>
     <script type="text/javascript" src="shared/js/dispatcher.js"></script>
     <script type="text/javascript" src="shared/js/websocket.js"></script>
+    <script type="text/javascript" src="shared/js/activeRoomStore.js"></script>
     <script type="text/javascript" src="js/standaloneAppStore.js"></script>
     <script type="text/javascript" src="js/standaloneClient.js"></script>
     <script type="text/javascript" src="js/standaloneRoomViews.js"></script>
     <script type="text/javascript" src="js/webapp.js"></script>
 
     <script>
       // Wait for all the localization notes to load
       window.addEventListener('localized', function() {
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -6,17 +6,51 @@
 
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.standaloneRoomViews = (function() {
   "use strict";
 
   var StandaloneRoomView = React.createClass({displayName: 'StandaloneRoomView',
+    mixins: [Backbone.Events],
+
+    propTypes: {
+      activeRoomStore:
+        React.PropTypes.instanceOf(loop.store.ActiveRoomStore).isRequired
+    },
+
+    getInitialState: function() {
+      return this.props.activeRoomStore.getStoreState();
+    },
+
+    componentWillMount: function() {
+      this.listenTo(this.props.activeRoomStore, "change",
+                    this._onActiveRoomStateChanged);
+    },
+
+    /**
+     * Handles a "change" event on the roomStore, and updates this.state
+     * to match the store.
+     *
+     * @private
+     */
+    _onActiveRoomStateChanged: function() {
+      this.setState(this.props.activeRoomStore.getStoreState());
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.activeRoomStore);
+    },
+
     render: function() {
-      return (React.DOM.div(null, "Room"));
+      return (
+        React.DOM.div(null, 
+          React.DOM.div(null, this.state.roomState)
+        )
+      );
     }
   });
 
   return {
     StandaloneRoomView: StandaloneRoomView
   };
 })();
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -6,17 +6,51 @@
 
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.standaloneRoomViews = (function() {
   "use strict";
 
   var StandaloneRoomView = React.createClass({
+    mixins: [Backbone.Events],
+
+    propTypes: {
+      activeRoomStore:
+        React.PropTypes.instanceOf(loop.store.ActiveRoomStore).isRequired
+    },
+
+    getInitialState: function() {
+      return this.props.activeRoomStore.getStoreState();
+    },
+
+    componentWillMount: function() {
+      this.listenTo(this.props.activeRoomStore, "change",
+                    this._onActiveRoomStateChanged);
+    },
+
+    /**
+     * Handles a "change" event on the roomStore, and updates this.state
+     * to match the store.
+     *
+     * @private
+     */
+    _onActiveRoomStateChanged: function() {
+      this.setState(this.props.activeRoomStore.getStoreState());
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.activeRoomStore);
+    },
+
     render: function() {
-      return (<div>Room</div>);
+      return (
+        <div>
+          <div>{this.state.roomState}</div>
+        </div>
+      );
     }
   });
 
   return {
     StandaloneRoomView: StandaloneRoomView
   };
 })();
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -872,17 +872,19 @@ loop.webapp = (function($, _, OT, mozL10
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
       feedbackApiClient: React.PropTypes.object.isRequired,
 
       // XXX New types for flux style
       standaloneAppStore: React.PropTypes.instanceOf(
-        loop.store.StandaloneAppStore).isRequired
+        loop.store.StandaloneAppStore).isRequired,
+      activeRoomStore: React.PropTypes.instanceOf(
+        loop.store.ActiveRoomStore).isRequired
     },
 
     getInitialState: function() {
       return this.props.standaloneAppStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.standaloneAppStore, "change", function() {
@@ -914,17 +916,21 @@ loop.webapp = (function($, _, OT, mozL10
                helper: this.props.helper, 
                notifications: this.props.notifications, 
                sdk: this.props.sdk, 
                feedbackApiClient: this.props.feedbackApiClient}
             )
           );
         }
         case "room": {
-          return loop.standaloneRoomViews.StandaloneRoomView(null);
+          return (
+            loop.standaloneRoomViews.StandaloneRoomView({
+              activeRoomStore: this.props.activeRoomStore}
+            )
+          );
         }
         case "home": {
           return HomeView(null);
         }
         default: {
           // The state hasn't been initialised yet, so don't display
           // anything to avoid flicker.
           return null;
@@ -964,25 +970,32 @@ loop.webapp = (function($, _, OT, mozL10
     });
 
     var standaloneAppStore = new loop.store.StandaloneAppStore({
       conversation: conversation,
       dispatcher: dispatcher,
       helper: helper,
       sdk: OT
     });
+    var activeRoomStore = new loop.store.ActiveRoomStore({
+      dispatcher: dispatcher,
+      // XXX Bug 1074702 will introduce a mozLoop compatible object for
+      // the standalone rooms.
+      mozLoop: {}
+    });
 
     React.renderComponent(WebappRootView({
       client: client, 
       conversation: conversation, 
       helper: helper, 
       notifications: notifications, 
       sdk: OT, 
       feedbackApiClient: feedbackApiClient, 
-      standaloneAppStore: standaloneAppStore}
+      standaloneAppStore: standaloneAppStore, 
+      activeRoomStore: activeRoomStore}
     ), document.querySelector("#main"));
 
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
     document.title = mozL10n.get("clientShortname2");
 
     dispatcher.dispatch(new sharedActions.ExtractTokenInfo({
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -872,17 +872,19 @@ loop.webapp = (function($, _, OT, mozL10
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
       feedbackApiClient: React.PropTypes.object.isRequired,
 
       // XXX New types for flux style
       standaloneAppStore: React.PropTypes.instanceOf(
-        loop.store.StandaloneAppStore).isRequired
+        loop.store.StandaloneAppStore).isRequired,
+      activeRoomStore: React.PropTypes.instanceOf(
+        loop.store.ActiveRoomStore).isRequired
     },
 
     getInitialState: function() {
       return this.props.standaloneAppStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.standaloneAppStore, "change", function() {
@@ -914,17 +916,21 @@ loop.webapp = (function($, _, OT, mozL10
                helper={this.props.helper}
                notifications={this.props.notifications}
                sdk={this.props.sdk}
                feedbackApiClient={this.props.feedbackApiClient}
             />
           );
         }
         case "room": {
-          return <loop.standaloneRoomViews.StandaloneRoomView/>;
+          return (
+            <loop.standaloneRoomViews.StandaloneRoomView
+              activeRoomStore={this.props.activeRoomStore}
+            />
+          );
         }
         case "home": {
           return <HomeView />;
         }
         default: {
           // The state hasn't been initialised yet, so don't display
           // anything to avoid flicker.
           return null;
@@ -964,25 +970,32 @@ loop.webapp = (function($, _, OT, mozL10
     });
 
     var standaloneAppStore = new loop.store.StandaloneAppStore({
       conversation: conversation,
       dispatcher: dispatcher,
       helper: helper,
       sdk: OT
     });
+    var activeRoomStore = new loop.store.ActiveRoomStore({
+      dispatcher: dispatcher,
+      // XXX Bug 1074702 will introduce a mozLoop compatible object for
+      // the standalone rooms.
+      mozLoop: {}
+    });
 
     React.renderComponent(<WebappRootView
       client={client}
       conversation={conversation}
       helper={helper}
       notifications={notifications}
       sdk={OT}
       feedbackApiClient={feedbackApiClient}
       standaloneAppStore={standaloneAppStore}
+      activeRoomStore={activeRoomStore}
     />, document.querySelector("#main"));
 
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
     document.title = mozL10n.get("clientShortname2");
 
     dispatcher.dispatch(new sharedActions.ExtractTokenInfo({
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -1,52 +1,74 @@
 var expect = chai.expect;
 
 describe("loop.roomViews", function () {
   "use strict";
 
-  var sandbox, dispatcher, roomStore, activeRoomStore, fakeWindow, fakeMozLoop,
-      fakeRoomId;
+  var ROOM_STATES = loop.store.ROOM_STATES;
+
+  var sandbox, dispatcher, roomStore, activeRoomStore, fakeWindow;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
     dispatcher = new loop.Dispatcher();
 
     fakeWindow = { document: {} };
     loop.shared.mixins.setRootObject(fakeWindow);
 
+    // XXX These stubs should be hoisted in a common file
+    // Bug 1040968
+    sandbox.stub(document.mozL10n, "get", function(x) {
+      return x;
+    });
+
+
     activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
       mozLoop: {}
     });
     roomStore = new loop.store.RoomStore({
       dispatcher: dispatcher,
       mozLoop: {},
       activeRoomStore: activeRoomStore
     });
   });
 
   afterEach(function() {
-    sinon.sandbox.restore();
+    sandbox.restore();
     loop.shared.mixins.setRootObject(window);
   });
 
   describe("DesktopRoomView", function() {
+    var view;
+
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         new loop.roomViews.DesktopRoomView({
           mozLoop: {},
           roomStore: roomStore
         }));
     }
 
     describe("#render", function() {
       it("should set document.title to store.serverData.roomName", function() {
         mountTestComponent();
 
-        activeRoomStore.setStoreState({serverData: {roomName: "fakeName"}});
+        activeRoomStore.setStoreState({roomName: "fakeName"});
 
         expect(fakeWindow.document.title).to.equal("fakeName");
       });
+
+      it("should render the GenericFailureView if the roomState is `FAILED`", function() {
+        activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversation.GenericFailureView);
+      });
+
+      // XXX Implement this when we do the rooms views in bug 1074686 and others.
+      it("should display the main view");
     });
   });
 });
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -1,21 +1,37 @@
 /* global chai */
 
 var expect = chai.expect;
 var sharedActions = loop.shared.actions;
 
 describe("loop.store.ActiveRoomStore", function () {
   "use strict";
 
-  var sandbox, dispatcher;
+  var ROOM_STATES = loop.store.ROOM_STATES;
+  var sandbox, dispatcher, store, fakeMozLoop;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
+    sandbox.useFakeTimers();
+
     dispatcher = new loop.Dispatcher();
+    sandbox.stub(dispatcher, "dispatch");
+
+    fakeMozLoop = {
+      rooms: {
+        get: sandbox.stub(),
+        join: sandbox.stub(),
+        refreshMembership: sandbox.stub(),
+        leave: sandbox.stub()
+      }
+    };
+
+    store = new loop.store.ActiveRoomStore(
+      {mozLoop: fakeMozLoop, dispatcher: dispatcher});
   });
 
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("#constructor", function() {
     it("should throw an error if the dispatcher is missing", function() {
@@ -26,97 +42,304 @@ describe("loop.store.ActiveRoomStore", f
 
     it("should throw an error if mozLoop is missing", function() {
       expect(function() {
         new loop.store.ActiveRoomStore({dispatcher: dispatcher});
       }).to.Throw(/mozLoop/);
     });
   });
 
+  describe("#roomFailure", function() {
+    var fakeError;
+
+    beforeEach(function() {
+      sandbox.stub(console, "error");
+
+      fakeError = new Error("fake");
+
+      store.setStoreState({
+        roomState: ROOM_STATES.READY
+      });
+    });
+
+    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 `FAILED`", function() {
+      store.roomFailure({error: fakeError});
+
+      expect(store._storeState.roomState).eql(ROOM_STATES.FAILED);
+    });
+  });
+
   describe("#setupWindowData", function() {
-    var store, fakeMozLoop, fakeToken, fakeRoomName;
+    var fakeToken, fakeRoomData;
 
     beforeEach(function() {
       fakeToken = "337-ff-54";
-      fakeRoomName = "Monkeys";
-      fakeMozLoop = {
-        rooms: { get: sandbox.stub() }
+      fakeRoomData = {
+        roomName: "Monkeys",
+        roomOwner: "Alfred",
+        roomUrl: "http://invalid"
       };
 
-      store = new loop.store.ActiveRoomStore(
-        {mozLoop: fakeMozLoop, dispatcher: dispatcher});
       fakeMozLoop.rooms.get.
         withArgs(fakeToken).
         callsArgOnWith(1, // index of callback argument
         store, // |this| to call it on
         null, // args to call the callback with...
-        {roomName: fakeRoomName}
+        fakeRoomData
       );
     });
 
-    it("should trigger a change event", function(done) {
-      store.on("change", function() {
-        done();
+    it("should set the state to `GATHER`",
+      function() {
+        store.setupWindowData(new sharedActions.SetupWindowData({
+          windowId: "42",
+          type: "room",
+          roomToken: fakeToken
+        }));
+
+        expect(store.getStoreState()).
+          to.have.property('roomState', ROOM_STATES.GATHER);
       });
 
-      dispatcher.dispatch(new sharedActions.SetupWindowData({
-        windowId: "42",
-        type: "room",
-        roomToken: fakeToken
-      }));
-    });
-
-    it("should set roomToken on the store from the action data",
-      function(done) {
-
-        store.once("change", function () {
-          expect(store.getStoreState()).
-            to.have.property('roomToken', fakeToken);
-          done();
-        });
-
-        dispatcher.dispatch(new sharedActions.SetupWindowData({
+    it("should dispatch an UpdateRoomInfo action if the get is successful",
+      function() {
+        store.setupWindowData(new sharedActions.SetupWindowData({
           windowId: "42",
           type: "room",
           roomToken: fakeToken
         }));
+
+        sinon.assert.calledTwice(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.UpdateRoomInfo(_.extend({
+            roomToken: fakeToken
+          }, fakeRoomData)));
       });
 
-    it("should set serverData.roomName from the getRoomData callback",
-      function(done) {
-
-        store.once("change", function () {
-          expect(store.getStoreState()).to.have.deep.property(
-            'serverData.roomName', fakeRoomName);
-          done();
-        });
-
-        dispatcher.dispatch(new sharedActions.SetupWindowData({
+    it("should dispatch a JoinRoom action if the get is successful",
+      function() {
+        store.setupWindowData(new sharedActions.SetupWindowData({
           windowId: "42",
           type: "room",
           roomToken: fakeToken
         }));
+
+        sinon.assert.calledTwice(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.JoinRoom());
       });
 
-    it("should set error on the store when getRoomData calls back an error",
-      function(done) {
+    it("should dispatch a RoomFailure action if the get fails",
+      function() {
 
         var fakeError = new Error("fake error");
         fakeMozLoop.rooms.get.
           withArgs(fakeToken).
           callsArgOnWith(1, // index of callback argument
           store, // |this| to call it on
           fakeError); // args to call the callback with...
 
-        store.once("change", function() {
-          expect(this.getStoreState()).to.have.property('error', fakeError);
-          done();
-        });
-
-        dispatcher.dispatch(new sharedActions.SetupWindowData({
+        store.setupWindowData(new sharedActions.SetupWindowData({
           windowId: "42",
           type: "room",
           roomToken: fakeToken
         }));
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.RoomFailure({
+            error: fakeError
+          }));
       });
+  });
 
+  describe("#updateRoomInfo", function() {
+    var fakeRoomInfo;
+
+    beforeEach(function() {
+      fakeRoomInfo = {
+        roomName: "Its a room",
+        roomOwner: "Me",
+        roomToken: "fakeToken",
+        roomUrl: "http://invalid"
+      };
+    });
+
+    it("should set the state to READY", function() {
+      store.updateRoomInfo(fakeRoomInfo);
+
+      expect(store._storeState.roomState).eql(ROOM_STATES.READY);
+    });
+
+    it("should save the room information", function() {
+      store.updateRoomInfo(fakeRoomInfo);
+
+      var state = store.getStoreState();
+      expect(state.roomName).eql(fakeRoomInfo.roomName);
+      expect(state.roomOwner).eql(fakeRoomInfo.roomOwner);
+      expect(state.roomToken).eql(fakeRoomInfo.roomToken);
+      expect(state.roomUrl).eql(fakeRoomInfo.roomUrl);
+    });
+  });
+
+  describe("#joinRoom", function() {
+    beforeEach(function() {
+      store.setStoreState({roomToken: "tokenFake"});
+    });
+
+    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() {
+      var responseData = {
+        apiKey: "keyFake",
+        sessionToken: "14327659860",
+        sessionId: "1357924680",
+        expires: 8
+      };
+
+      fakeMozLoop.rooms.join.callsArgWith(1, null, responseData);
+
+      store.joinRoom();
+
+      sinon.assert.calledOnce(dispatcher.dispatch);
+      sinon.assert.calledWith(dispatcher.dispatch,
+        new sharedActions.JoinedRoom(responseData));
+    });
+
+    it("should dispatch `RoomFailure` on error", function() {
+      var fakeError = new Error("fake");
+
+      fakeMozLoop.rooms.join.callsArgWith(1, fakeError);
+
+      store.joinRoom();
+
+      sinon.assert.calledOnce(dispatcher.dispatch);
+      sinon.assert.calledWith(dispatcher.dispatch,
+        new sharedActions.RoomFailure({error: fakeError}));
+    });
+  });
+
+  describe("#joinedRoom", function() {
+    var fakeJoinedData;
+
+    beforeEach(function() {
+      fakeJoinedData = {
+        apiKey: "9876543210",
+        sessionToken: "12563478",
+        sessionId: "15263748",
+        expires: 20
+      };
+
+      store.setStoreState({
+        roomToken: "fakeToken"
+      });
+    });
+
+    it("should set the state to `JOINED`", function() {
+      store.joinedRoom(fakeJoinedData);
+
+      expect(store._storeState.roomState).eql(ROOM_STATES.JOINED);
+    });
+
+    it("should store the session and api values", function() {
+      store.joinedRoom(fakeJoinedData);
+
+      var state = store.getStoreState();
+      expect(state.apiKey).eql(fakeJoinedData.apiKey);
+      expect(state.sessionToken).eql(fakeJoinedData.sessionToken);
+      expect(state.sessionId).eql(fakeJoinedData.sessionId);
+    });
+
+    it("should call mozLoop.rooms.refreshMembership before the expiresTime",
+      function() {
+        store.joinedRoom(fakeJoinedData);
+
+        sandbox.clock.tick(fakeJoinedData.expires * 1000);
+
+        sinon.assert.calledOnce(fakeMozLoop.rooms.refreshMembership);
+        sinon.assert.calledWith(fakeMozLoop.rooms.refreshMembership,
+          "fakeToken", "12563478");
+    });
+
+    it("should call mozLoop.rooms.refreshMembership before the next expiresTime",
+      function() {
+        fakeMozLoop.rooms.refreshMembership.callsArgWith(2,
+          null, {expires: 40});
+
+        store.joinedRoom(fakeJoinedData);
+
+        // Clock tick for the first expiry time (which
+        // sets up the refreshMembership).
+        sandbox.clock.tick(fakeJoinedData.expires * 1000);
+
+        // Clock tick for expiry time in the refresh membership response.
+        sandbox.clock.tick(40000);
+
+        sinon.assert.calledTwice(fakeMozLoop.rooms.refreshMembership);
+        sinon.assert.calledWith(fakeMozLoop.rooms.refreshMembership,
+          "fakeToken", "12563478");
+    });
+
+    it("should dispatch `RoomFailure` if the refreshMembership call failed",
+      function() {
+        var fakeError = new Error("fake");
+        fakeMozLoop.rooms.refreshMembership.callsArgWith(2, fakeError);
+
+        store.joinedRoom(fakeJoinedData);
+
+        // Clock tick for the first expiry time (which
+        // sets up the refreshMembership).
+        sandbox.clock.tick(fakeJoinedData.expires * 1000);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWith(dispatcher.dispatch,
+          new sharedActions.RoomFailure({
+            error: fakeError
+          }));
+    });
+  });
+
+  describe("#windowUnload", function() {
+    beforeEach(function() {
+      store.setStoreState({
+        roomState: ROOM_STATES.JOINED,
+        roomToken: "fakeToken",
+        sessionToken: "1627384950"
+      });
+    });
+
+    it("should clear any existing timeout", function() {
+      sandbox.stub(window, "clearTimeout");
+      store._timeout = {};
+
+      store.windowUnload();
+
+      sinon.assert.calledOnce(clearTimeout);
+    });
+
+    it("should call mozLoop.rooms.leave", function() {
+      store.windowUnload();
+
+      sinon.assert.calledOnce(fakeMozLoop.rooms.leave);
+      sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave,
+        "fakeToken", "1627384950");
+    });
+
+    it("should set the state to ready", function() {
+      store.windowUnload();
+
+      expect(store._storeState.roomState).eql(ROOM_STATES.READY);
+    });
   });
 });
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -35,16 +35,17 @@
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/shared/js/feedbackApiClient.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
+  <script src="../../content/shared/js/activeRoomStore.js"></script>
   <script src="../../standalone/content/js/multiplexGum.js"></script>
   <script src="../../standalone/content/js/standaloneAppStore.js"></script>
   <script src="../../standalone/content/js/standaloneClient.js"></script>
   <script src="../../standalone/content/js/standaloneRoomViews.js"></script>
   <script src="../../standalone/content/js/webapp.js"></script>
  <!-- Test scripts -->
   <script src="standalone_client_test.js"></script>
   <script src="standaloneAppStore_test.js"></script>
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -577,43 +577,48 @@ describe("loop.webapp", function() {
         sinon.assert.calledOnce(fakeAudio.play);
         expect(fakeAudio.loop).to.equal(false);
       });
     });
   });
 
   describe("WebappRootView", function() {
     var helper, sdk, conversationModel, client, props, standaloneAppStore;
-    var dispatcher;
+    var dispatcher, activeRoomStore;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.webapp.WebappRootView({
         client: client,
         helper: helper,
         notifications: notifications,
         sdk: sdk,
         conversation: conversationModel,
         feedbackApiClient: feedbackApiClient,
-        standaloneAppStore: standaloneAppStore
+        standaloneAppStore: standaloneAppStore,
+        activeRoomStore: activeRoomStore
       }));
     }
 
     beforeEach(function() {
       helper = new sharedUtils.Helper();
       sdk = {
         checkSystemRequirements: function() { return true; }
       };
       conversationModel = new sharedModels.ConversationModel({}, {
         sdk: sdk
       });
       client = new loop.StandaloneClient({
         baseServerUrl: "fakeUrl"
       });
       dispatcher = new loop.Dispatcher();
+      activeRoomStore = new loop.store.ActiveRoomStore({
+        dispatcher: dispatcher,
+        mozLoop: {}
+      });
       standaloneAppStore = new loop.store.StandaloneAppStore({
         dispatcher: dispatcher,
         sdk: sdk,
         helper: helper,
         conversation: conversationModel
       });
       // Stub this to stop the StartConversationView kicking in the request and
       // follow-ups.