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=RyanVM
authorMark Banner <standard8@mozilla.com>
Thu, 06 Nov 2014 20:53:49 +0000
changeset 214469 ca16d47debf88e9fbe103c16fa3184e5ed218faa
parent 214468 e5503802d876767bbc3ae79196440663c44c3639
child 214470 c2ced33d9744df346e1b6b50c6710cf03cb4e619
push id51494
push userkwierso@gmail.com
push dateFri, 07 Nov 2014 03:08:20 +0000
treeherdermozilla-inbound@c4b831696f15 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnperriault, RyanVM
bugs1074688
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 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=RyanVM
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
@@ -886,17 +886,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() {
@@ -928,17 +930,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;
@@ -978,25 +984,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
@@ -886,17 +886,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() {
@@ -928,17 +930,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;
@@ -978,25 +984,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.