Bug 1074686 - Test/impl EmptyRoomView, store, and actions, r=Standard8 a=loop-only
authorDan Mosedale <dmose@meer.net>
Tue, 14 Oct 2014 15:22:35 -0700
changeset 235083 b4d1f862183ec39afbb4a5a5f66a0ed410b4fe23
parent 235082 11deca361be25acccbaa336f870ed2a1c9d871dc
child 235084 9b1acb47ef6899b1b978a095843ea62230c849e8
push id611
push userraliiev@mozilla.com
push dateMon, 05 Jan 2015 23:23:16 +0000
treeherdermozilla-release@345cd3b9c445 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8, loop-only
bugs1074686
milestone35.0a2
Bug 1074686 - Test/impl EmptyRoomView, store, and actions, r=Standard8 a=loop-only
browser/components/loop/content/conversation.html
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/localRoomStore.js
browser/components/loop/jar.mn
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/desktop-local/index.html
browser/components/loop/test/desktop-local/roomViews_test.js
browser/components/loop/test/shared/conversationStore_test.js
browser/components/loop/test/shared/index.html
browser/components/loop/test/shared/localRoomStore_test.js
browser/components/loop/test/shared/mixins_test.js
browser/components/loop/ui/index.html
--- a/browser/components/loop/content/conversation.html
+++ b/browser/components/loop/content/conversation.html
@@ -30,15 +30,17 @@
     <script type="text/javascript" src="loop/shared/js/mixins.js"></script>
     <script type="text/javascript" src="loop/shared/js/views.js"></script>
     <script type="text/javascript" src="loop/shared/js/feedbackApiClient.js"></script>
     <script type="text/javascript" src="loop/shared/js/actions.js"></script>
     <script type="text/javascript" src="loop/shared/js/validate.js"></script>
     <script type="text/javascript" src="loop/shared/js/dispatcher.js"></script>
     <script type="text/javascript" src="loop/shared/js/otSdkDriver.js"></script>
     <script type="text/javascript" src="loop/shared/js/conversationStore.js"></script>
+    <script type="text/javascript" src="loop/shared/js/localRoomStore.js"></script>
     <script type="text/javascript" src="loop/js/conversationViews.js"></script>
     <script type="text/javascript" src="loop/shared/js/websocket.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
     <script type="text/javascript" src="loop/js/conversationViews.js"></script>
+    <script type="text/javascript" src="loop/js/roomViews.js"></script>
     <script type="text/javascript" src="loop/js/conversation.js"></script>
   </body>
 </html>
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -9,18 +9,21 @@
 
 var loop = loop || {};
 loop.conversation = (function(mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedMixins = loop.shared.mixins;
   var sharedModels = loop.shared.models;
+  var sharedActions = loop.shared.actions;
+
   var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
   var CallIdentifierView = loop.conversationViews.CallIdentifierView;
+  var EmptyRoomView = loop.roomViews.EmptyRoomView;
 
   var IncomingCallView = React.createClass({displayName: 'IncomingCallView',
     mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
@@ -516,40 +519,52 @@ loop.conversation = (function(mozL10n) {
       console.error("Failed initiating the call session.");
     },
   });
 
   /**
    * Master controller view for handling if incoming or outgoing calls are
    * in progress, and hence, which view to display.
    */
-  var ConversationControllerView = React.createClass({displayName: 'ConversationControllerView',
+  var AppControllerView = React.createClass({displayName: 'AppControllerView',
     propTypes: {
       // XXX Old types required for incoming call view.
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
 
       // XXX New types for OutgoingConversationView
       store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+
+      // if not passed, this is not a room view
+      localRoomStore: React.PropTypes.instanceOf(loop.store.LocalRoomStore)
     },
 
     getInitialState: function() {
       return this.props.store.attributes;
     },
 
     componentWillMount: function() {
       this.props.store.on("change:outgoing", function() {
         this.setState(this.props.store.attributes);
       }, this);
     },
 
     render: function() {
+      if (this.props.localRoomStore) {
+        return (
+          EmptyRoomView({
+            mozLoop: navigator.mozLoop, 
+            localRoomStore: this.props.localRoomStore}
+          )
+        );
+      }
+
       // Don't display anything, until we know what type of call we are.
       if (this.state.outgoing === undefined) {
         return null;
       }
 
       if (this.state.outgoing) {
         return (OutgoingConversationView({
           store: this.props.store, 
@@ -605,54 +620,73 @@ loop.conversation = (function(mozL10n) {
       {sdk: window.OT}   // Model dependencies
     );
 
     // Obtain the callId and pass it through
     var helper = new loop.shared.utils.Helper();
     var locationHash = helper.locationHash();
     var callId;
     var outgoing;
+    var localRoomStore;
 
-    var hash = locationHash.match(/\#incoming\/(.*)/);
+    // XXX removeMe, along with noisy comment at the beginning of
+    // conversation_test.js "when locationHash begins with #room".
+    if (navigator.mozLoop.getLoopBoolPref("test.alwaysUseRooms")) {
+      locationHash = "#room/32";
+    }
+
+    var hash = locationHash.match(/#incoming\/(.*)/);
     if (hash) {
       callId = hash[1];
       outgoing = false;
+    } else if (hash = locationHash.match(/#room\/(.*)/)) {
+      localRoomStore = new loop.store.LocalRoomStore({
+        dispatcher: dispatcher,
+        mozLoop: navigator.mozLoop
+      });
     } else {
-      hash = locationHash.match(/\#outgoing\/(.*)/);
+      hash = locationHash.match(/#outgoing\/(.*)/);
       if (hash) {
         callId = hash[1];
         outgoing = true;
       }
     }
 
     conversation.set({callId: callId});
 
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
       navigator.mozLoop.releaseCallData(callId);
     });
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
 
-    React.renderComponent(ConversationControllerView({
+    React.renderComponent(AppControllerView({
+      localRoomStore: localRoomStore, 
       store: conversationStore, 
       client: client, 
       conversation: conversation, 
       dispatcher: dispatcher, 
       sdk: window.OT}
     ), document.querySelector('#main'));
 
+   if (localRoomStore) {
+      dispatcher.dispatch(
+        new sharedActions.SetupEmptyRoom({localRoomId: hash[1]}));
+      return;
+    }
+
     dispatcher.dispatch(new loop.shared.actions.GatherCallData({
       callId: callId,
       outgoing: outgoing
     }));
   }
 
   return {
-    ConversationControllerView: ConversationControllerView,
+    AppControllerView: AppControllerView,
     IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     IncomingCallFailedView: IncomingCallFailedView,
     init: init
   };
 })(document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.conversation.init);
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -9,18 +9,21 @@
 
 var loop = loop || {};
 loop.conversation = (function(mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedMixins = loop.shared.mixins;
   var sharedModels = loop.shared.models;
+  var sharedActions = loop.shared.actions;
+
   var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
   var CallIdentifierView = loop.conversationViews.CallIdentifierView;
+  var EmptyRoomView = loop.roomViews.EmptyRoomView;
 
   var IncomingCallView = React.createClass({
     mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
@@ -516,40 +519,52 @@ loop.conversation = (function(mozL10n) {
       console.error("Failed initiating the call session.");
     },
   });
 
   /**
    * Master controller view for handling if incoming or outgoing calls are
    * in progress, and hence, which view to display.
    */
-  var ConversationControllerView = React.createClass({
+  var AppControllerView = React.createClass({
     propTypes: {
       // XXX Old types required for incoming call view.
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
 
       // XXX New types for OutgoingConversationView
       store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+
+      // if not passed, this is not a room view
+      localRoomStore: React.PropTypes.instanceOf(loop.store.LocalRoomStore)
     },
 
     getInitialState: function() {
       return this.props.store.attributes;
     },
 
     componentWillMount: function() {
       this.props.store.on("change:outgoing", function() {
         this.setState(this.props.store.attributes);
       }, this);
     },
 
     render: function() {
+      if (this.props.localRoomStore) {
+        return (
+          <EmptyRoomView
+            mozLoop={navigator.mozLoop}
+            localRoomStore={this.props.localRoomStore}
+          />
+        );
+      }
+
       // Don't display anything, until we know what type of call we are.
       if (this.state.outgoing === undefined) {
         return null;
       }
 
       if (this.state.outgoing) {
         return (<OutgoingConversationView
           store={this.props.store}
@@ -605,54 +620,73 @@ loop.conversation = (function(mozL10n) {
       {sdk: window.OT}   // Model dependencies
     );
 
     // Obtain the callId and pass it through
     var helper = new loop.shared.utils.Helper();
     var locationHash = helper.locationHash();
     var callId;
     var outgoing;
+    var localRoomStore;
 
-    var hash = locationHash.match(/\#incoming\/(.*)/);
+    // XXX removeMe, along with noisy comment at the beginning of
+    // conversation_test.js "when locationHash begins with #room".
+    if (navigator.mozLoop.getLoopBoolPref("test.alwaysUseRooms")) {
+      locationHash = "#room/32";
+    }
+
+    var hash = locationHash.match(/#incoming\/(.*)/);
     if (hash) {
       callId = hash[1];
       outgoing = false;
+    } else if (hash = locationHash.match(/#room\/(.*)/)) {
+      localRoomStore = new loop.store.LocalRoomStore({
+        dispatcher: dispatcher,
+        mozLoop: navigator.mozLoop
+      });
     } else {
-      hash = locationHash.match(/\#outgoing\/(.*)/);
+      hash = locationHash.match(/#outgoing\/(.*)/);
       if (hash) {
         callId = hash[1];
         outgoing = true;
       }
     }
 
     conversation.set({callId: callId});
 
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
       navigator.mozLoop.releaseCallData(callId);
     });
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
 
-    React.renderComponent(<ConversationControllerView
+    React.renderComponent(<AppControllerView
+      localRoomStore={localRoomStore}
       store={conversationStore}
       client={client}
       conversation={conversation}
       dispatcher={dispatcher}
       sdk={window.OT}
     />, document.querySelector('#main'));
 
+   if (localRoomStore) {
+      dispatcher.dispatch(
+        new sharedActions.SetupEmptyRoom({localRoomId: hash[1]}));
+      return;
+    }
+
     dispatcher.dispatch(new loop.shared.actions.GatherCallData({
       callId: callId,
       outgoing: outgoing
     }));
   }
 
   return {
-    ConversationControllerView: ConversationControllerView,
+    AppControllerView: AppControllerView,
     IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     IncomingCallFailedView: IncomingCallFailedView,
     init: init
   };
 })(document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.conversation.init);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/roomViews.js
@@ -0,0 +1,109 @@
+/** @jsx React.DOM */
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true, React */
+
+var loop = loop || {};
+loop.roomViews = (function(mozL10n) {
+  "use strict";
+
+  /**
+   * Root object, by default set to window.
+   * @type {DOMWindow|Object}
+   */
+  var rootObject = window;
+
+  /**
+   * Sets a new root object. This is useful for testing native DOM events so we
+   * can fake them.
+   *
+   * @param {Object}
+   */
+  function setRootObject(obj) {
+    rootObject = obj;
+  }
+
+  var EmptyRoomView = React.createClass({displayName: 'EmptyRoomView',
+    mixins: [Backbone.Events],
+
+    propTypes: {
+      mozLoop:
+        React.PropTypes.object.isRequired,
+      localRoomStore:
+        React.PropTypes.instanceOf(loop.store.LocalRoomStore).isRequired,
+    },
+
+    getInitialState: function() {
+      return this.props.localRoomStore.getStoreState();
+    },
+
+    componentWillMount: function() {
+      this.listenTo(this.props.localRoomStore, "change",
+        this._onLocalRoomStoreChanged);
+    },
+
+    componentDidMount: function() {
+      // XXXremoveMe (just the conditional itself) in patch 2 for bug 1074686,
+      // once the addCallback stuff lands
+      if (this.props.mozLoop.rooms && this.props.mozLoop.rooms.addCallback) {
+        this.props.mozLoop.rooms.addCallback(
+          this.state.localRoomId,
+          "RoomCreationError", this.onCreationError);
+      }
+    },
+
+    /**
+     * Attached to the "RoomCreationError" with mozLoop.rooms.addCallback,
+     * which is fired mozLoop.rooms.createRoom from the panel encounters an
+     * error while attempting to create the room for this view.
+     *
+     * @param {Error} err - JS Error object with info about the problem
+     */
+    onCreationError: function(err) {
+      // XXX put up a user friendly error instead of this
+      rootObject.console.error("EmptyRoomView creation error: ", err);
+    },
+
+    /**
+     * Handles a "change" event on the localRoomStore, and updates this.state
+     * to match the store.
+     *
+     * @private
+     */
+    _onLocalRoomStoreChanged: function() {
+      this.setState(this.props.localRoomStore.getStoreState());
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.localRoomStore);
+
+      // XXXremoveMe (just the conditional itself) in patch 2 for bug 1074686,
+      // once the addCallback stuff lands
+      if (this.props.mozLoop.rooms && this.props.mozLoop.rooms.removeCallback) {
+        this.props.mozLoop.rooms.removeCallback(
+          this.state.localRoomId,
+          "RoomCreationError", this.onCreationError);
+      }
+    },
+
+    render: function() {
+      // XXX switch this to use the document title mixin once bug 1081079 lands
+      if (this.state.serverData && this.state.serverData.roomName) {
+        rootObject.document.title = this.state.serverData.roomName;
+      }
+
+      return (
+        React.DOM.div({className: "goat"})
+      );
+    }
+  });
+
+  return {
+    setRootObject: setRootObject,
+    EmptyRoomView: EmptyRoomView
+  };
+
+})(document.mozL10n || navigator.mozL10n);;
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -0,0 +1,109 @@
+/** @jsx React.DOM */
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true, React */
+
+var loop = loop || {};
+loop.roomViews = (function(mozL10n) {
+  "use strict";
+
+  /**
+   * Root object, by default set to window.
+   * @type {DOMWindow|Object}
+   */
+  var rootObject = window;
+
+  /**
+   * Sets a new root object. This is useful for testing native DOM events so we
+   * can fake them.
+   *
+   * @param {Object}
+   */
+  function setRootObject(obj) {
+    rootObject = obj;
+  }
+
+  var EmptyRoomView = React.createClass({
+    mixins: [Backbone.Events],
+
+    propTypes: {
+      mozLoop:
+        React.PropTypes.object.isRequired,
+      localRoomStore:
+        React.PropTypes.instanceOf(loop.store.LocalRoomStore).isRequired,
+    },
+
+    getInitialState: function() {
+      return this.props.localRoomStore.getStoreState();
+    },
+
+    componentWillMount: function() {
+      this.listenTo(this.props.localRoomStore, "change",
+        this._onLocalRoomStoreChanged);
+    },
+
+    componentDidMount: function() {
+      // XXXremoveMe (just the conditional itself) in patch 2 for bug 1074686,
+      // once the addCallback stuff lands
+      if (this.props.mozLoop.rooms && this.props.mozLoop.rooms.addCallback) {
+        this.props.mozLoop.rooms.addCallback(
+          this.state.localRoomId,
+          "RoomCreationError", this.onCreationError);
+      }
+    },
+
+    /**
+     * Attached to the "RoomCreationError" with mozLoop.rooms.addCallback,
+     * which is fired mozLoop.rooms.createRoom from the panel encounters an
+     * error while attempting to create the room for this view.
+     *
+     * @param {Error} err - JS Error object with info about the problem
+     */
+    onCreationError: function(err) {
+      // XXX put up a user friendly error instead of this
+      rootObject.console.error("EmptyRoomView creation error: ", err);
+    },
+
+    /**
+     * Handles a "change" event on the localRoomStore, and updates this.state
+     * to match the store.
+     *
+     * @private
+     */
+    _onLocalRoomStoreChanged: function() {
+      this.setState(this.props.localRoomStore.getStoreState());
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.localRoomStore);
+
+      // XXXremoveMe (just the conditional itself) in patch 2 for bug 1074686,
+      // once the addCallback stuff lands
+      if (this.props.mozLoop.rooms && this.props.mozLoop.rooms.removeCallback) {
+        this.props.mozLoop.rooms.removeCallback(
+          this.state.localRoomId,
+          "RoomCreationError", this.onCreationError);
+      }
+    },
+
+    render: function() {
+      // XXX switch this to use the document title mixin once bug 1081079 lands
+      if (this.state.serverData && this.state.serverData.roomName) {
+        rootObject.document.title = this.state.serverData.roomName;
+      }
+
+      return (
+        <div className="goat"/>
+      );
+    }
+  });
+
+  return {
+    setRootObject: setRootObject,
+    EmptyRoomView: EmptyRoomView
+  };
+
+})(document.mozL10n || navigator.mozL10n);;
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -127,11 +127,21 @@ loop.shared.actions = (function() {
       enabled: Boolean
     }),
 
     /**
      * Retrieves room list.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     GetAllRooms: Action.define("getAllRooms", {
-    })
+    }),
+
+    /**
+     * Primes localRoomStore with roomLocalId, which triggers the EmptyRoomView
+     * to do any necessary setup.
+     *
+     * XXX should move to localRoomActions module
+     */
+    SetupEmptyRoom: Action.define("setupEmptyRoom", {
+      localRoomId: String
+    }),
   };
 })();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/localRoomStore.js
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true */
+
+var loop = loop || {};
+loop.store = loop.store || {};
+loop.store.LocalRoomStore = (function() {
+  "use strict";
+
+  var sharedActions = loop.shared.actions;
+
+  /**
+   * 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
+   * @param {loop.Dispatcher} options.dispatch - The dispatcher for dispatching
+   *                            actions and registering to consume them.
+   * @param {MozLoop}         options.mozLoop - MozLoop API provider object
+   */
+  function LocalRoomStore(options) {
+    options = options || {};
+
+    if (!options.dispatcher) {
+      throw new Error("Missing option dispatcher");
+    }
+    this.dispatcher = options.dispatcher;
+
+    if (!options.mozLoop) {
+      throw new Error("Missing option mozLoop");
+    }
+    this.mozLoop = options.mozLoop;
+
+    this.dispatcher.register(this, ["setupEmptyRoom"]);
+  }
+
+  LocalRoomStore.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
+     *
+     * @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.
+     *
+     * @property {String} localRoomId - profile-local identifier used with
+     *                                  the MozLoop API.
+     */
+    _storeState: {
+    },
+
+    getStoreState: function() {
+      return this._storeState;
+    },
+
+    setStoreState: function(state) {
+      this._storeState = state;
+      this.trigger("change");
+    },
+
+    /**
+     * Proxy to mozLoop.rooms.getRoomData for setupEmptyRoom action.
+     *
+     * XXXremoveMe Can probably be removed when bug 1074664 lands.
+     *
+     * @param {sharedActions.setupEmptyRoom} actionData
+     * @param {Function} cb Callback(error, roomData)
+     */
+    _fetchRoomData: function(actionData, cb) {
+      if (this.mozLoop.rooms && this.mozLoop.rooms.getRoomData) {
+        this.mozLoop.rooms.getRoomData(actionData.localRoomId, cb);
+      } else {
+        cb(null, {roomName: "Donkeys"});
+      }
+    },
+
+    /**
+     * Execute setupEmptyRoom event action from the dispatcher.  This primes
+     * the store with the localRoomId, 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.
+     *
+     * When the room name gets set, that will trigger the view to display
+     * that name.
+     *
+     * @param {sharedActions.setupEmptyRoom} actionData
+     */
+    setupEmptyRoom: function(actionData) {
+      this._fetchRoomData(actionData, function(error, roomData) {
+        this.setStoreState({
+          error: error,
+          localRoomId: actionData.localRoomId,
+          serverData: roomData
+        });
+      }.bind(this));
+    }
+
+  }, Backbone.Events);
+
+  return LocalRoomStore;
+
+})();
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -12,16 +12,17 @@ browser.jar:
 
   # Desktop script
   content/browser/loop/js/client.js                 (content/js/client.js)
   content/browser/loop/js/conversation.js           (content/js/conversation.js)
   content/browser/loop/js/otconfig.js               (content/js/otconfig.js)
   content/browser/loop/js/panel.js                  (content/js/panel.js)
   content/browser/loop/js/contacts.js               (content/js/contacts.js)
   content/browser/loop/js/conversationViews.js      (content/js/conversationViews.js)
+  content/browser/loop/js/roomViews.js              (content/js/roomViews.js)
 
   # Shared styles
   content/browser/loop/shared/css/reset.css         (content/shared/css/reset.css)
   content/browser/loop/shared/css/common.css        (content/shared/css/common.css)
   content/browser/loop/shared/css/panel.css         (content/shared/css/panel.css)
   content/browser/loop/shared/css/conversation.css  (content/shared/css/conversation.css)
   content/browser/loop/shared/css/contacts.css      (content/shared/css/contacts.css)
 
@@ -52,16 +53,17 @@ browser.jar:
   content/browser/loop/shared/img/icons-10x10.svg               (content/shared/img/icons-10x10.svg)
   content/browser/loop/shared/img/icons-14x14.svg               (content/shared/img/icons-14x14.svg)
   content/browser/loop/shared/img/icons-16x16.svg               (content/shared/img/icons-16x16.svg)
 
   # Shared scripts
   content/browser/loop/shared/js/actions.js           (content/shared/js/actions.js)
   content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js)
   content/browser/loop/shared/js/roomListStore.js     (content/shared/js/roomListStore.js)
+  content/browser/loop/shared/js/localRoomStore.js    (content/shared/js/localRoomStore.js)
   content/browser/loop/shared/js/dispatcher.js        (content/shared/js/dispatcher.js)
   content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
   content/browser/loop/shared/js/models.js            (content/shared/js/models.js)
   content/browser/loop/shared/js/mixins.js            (content/shared/js/mixins.js)
   content/browser/loop/shared/js/otSdkDriver.js       (content/shared/js/otSdkDriver.js)
   content/browser/loop/shared/js/views.js             (content/shared/js/views.js)
   content/browser/loop/shared/js/utils.js             (content/shared/js/utils.js)
   content/browser/loop/shared/js/validate.js          (content/shared/js/validate.js)
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -1,14 +1,16 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 var expect = chai.expect;
 
 describe("loop.conversationViews", function () {
+  "use strict";
+
   var sharedUtils = loop.shared.utils;
   var sandbox, oldTitle, view, dispatcher, contact;
 
   var CALL_STATES = loop.store.CALL_STATES;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -85,35 +85,72 @@ describe("loop.conversation", function()
         overrideGuidStorage: sinon.stub()
       };
     });
 
     afterEach(function() {
       delete window.OT;
     });
 
-    it("should initalize L10n", function() {
+    it("should initialize L10n", function() {
       loop.conversation.init();
 
       sinon.assert.calledOnce(document.mozL10n.initialize);
       sinon.assert.calledWithExactly(document.mozL10n.initialize,
         navigator.mozLoop);
     });
 
-    it("should create the ConversationControllerView", function() {
+    it("should create the AppControllerView", function() {
       loop.conversation.init();
 
       sinon.assert.calledOnce(React.renderComponent);
       sinon.assert.calledWith(React.renderComponent,
         sinon.match(function(value) {
           return TestUtils.isDescriptorOfType(value,
-            loop.conversation.ConversationControllerView);
+            loop.conversation.AppControllerView);
       }));
     });
 
+    describe("when locationHash begins with #room", function () {
+      // XXX must stay in sync with "test.alwaysUseRooms" pref check
+      // in conversation.jsx:init until we remove that code, which should
+      // happen in the second patch in bug 1074686, at which time this comment
+      // can go away as well.
+      var fakeRoomID = "32";
+
+      beforeEach(function() {
+        loop.shared.utils.Helper.prototype.locationHash
+          .returns("#room/" + fakeRoomID);
+
+        sandbox.stub(loop.store, "LocalRoomStore");
+      });
+
+      it("should create a localRoomStore", function() {
+        loop.conversation.init();
+
+        sinon.assert.calledOnce(loop.store.LocalRoomStore);
+        sinon.assert.calledWithNew(loop.store.LocalRoomStore);
+        sinon.assert.calledWithExactly(loop.store.LocalRoomStore,
+          sinon.match({
+            dispatcher: sinon.match.instanceOf(loop.Dispatcher),
+            mozLoop: sinon.match.same(navigator.mozLoop)
+          }));
+      });
+
+      it("should dispatch SetupEmptyRoom with localRoomId from locationHash",
+        function() {
+
+          loop.conversation.init();
+
+          sinon.assert.calledOnce(loop.Dispatcher.prototype.dispatch);
+          sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch,
+            new loop.shared.actions.SetupEmptyRoom({localRoomId: fakeRoomID}));
+        });
+    });
+
     it("should trigger a gatherCallData action", function() {
       loop.conversation.init();
 
       sinon.assert.calledOnce(loop.Dispatcher.prototype.dispatch);
       sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch,
         new loop.shared.actions.GatherCallData({
           callId: "42",
           outgoing: false
@@ -133,21 +170,22 @@ describe("loop.conversation", function()
             outgoing: true
           }));
       });
   });
 
   describe("ConversationControllerView", function() {
     var store, conversation, client, ccView, oldTitle, dispatcher;
 
-    function mountTestComponent() {
+    function mountTestComponent(localRoomStore) {
       return TestUtils.renderIntoDocument(
-        loop.conversation.ConversationControllerView({
+        loop.conversation.AppControllerView({
           client: client,
           conversation: conversation,
+          localRoomStore: localRoomStore,
           sdk: {},
           store: store
         }));
     }
 
     beforeEach(function() {
       oldTitle = document.title;
       client = new loop.Client();
@@ -188,16 +226,32 @@ describe("loop.conversation", function()
     it("should display the IncomingConversationView for incoming calls", function() {
       store.set({outgoing: false});
 
       ccView = mountTestComponent();
 
       TestUtils.findRenderedComponentWithType(ccView,
         loop.conversation.IncomingConversationView);
     });
+
+    it("should display the EmptyRoomView for rooms", function() {
+      navigator.mozLoop.rooms = {
+        addCallback: function() {},
+        removeCallback: function() {}
+      };
+      var localRoomStore = new loop.store.LocalRoomStore({
+        mozLoop: navigator.mozLoop,
+        dispatcher: dispatcher
+      });
+
+      ccView = mountTestComponent(localRoomStore);
+
+      TestUtils.findRenderedComponentWithType(ccView,
+        loop.roomViews.EmptyRoomView);
+    });
   });
 
   describe("IncomingConversationView", function() {
     var conversation, client, icView, oldTitle;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversation.IncomingConversationView({
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -40,25 +40,28 @@
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.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/otSdkDriver.js"></script>
   <script src="../../content/shared/js/roomListStore.js"></script>
   <script src="../../content/js/client.js"></script>
+  <script src="../../content/shared/js/localRoomStore.js"></script>
+  <script src="../../content/js/roomViews.js"></script>
   <script src="../../content/js/conversationViews.js"></script>
   <script src="../../content/js/conversation.js"></script>
   <script type="text/javascript;version=1.8" src="../../content/js/contacts.js"></script>
   <script src="../../content/js/panel.js"></script>
 
   <!-- Test scripts -->
   <script src="client_test.js"></script>
   <script src="conversation_test.js"></script>
   <script src="panel_test.js"></script>
+  <script src="roomViews_test.js"></script>
   <script src="conversationViews_test.js"></script>
   <script>
     // Stop the default init functions running to avoid conflicts in tests
     document.removeEventListener('DOMContentLoaded', loop.panel.init);
     document.removeEventListener('DOMContentLoaded', loop.conversation.init);
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -0,0 +1,94 @@
+var expect = chai.expect;
+
+describe("loop.roomViews", function () {
+  "use strict";
+
+  var store, fakeWindow, sandbox, fakeAddCallback, fakeMozLoop,
+    fakeRemoveCallback, fakeRoomId, fakeWindow;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+
+    fakeRoomId = "fakeRoomId";
+    fakeAddCallback =
+      sandbox.stub().withArgs(fakeRoomId, "RoomCreationError");
+    fakeRemoveCallback =
+      sandbox.stub().withArgs(fakeRoomId, "RoomCreationError");
+    fakeMozLoop = { rooms: { addCallback: fakeAddCallback,
+                             removeCallback: fakeRemoveCallback } };
+
+    fakeWindow = { document: {} };
+    loop.roomViews.setRootObject(fakeWindow);
+
+    store = new loop.store.LocalRoomStore({
+      dispatcher: { register: function() {} },
+      mozLoop: fakeMozLoop
+    });
+    store.setStoreState({localRoomId: fakeRoomId});
+  });
+
+  afterEach(function() {
+    sinon.sandbox.restore();
+    loop.roomViews.setRootObject(window);
+  });
+
+  describe("EmptyRoomView", function() {
+    function mountTestComponent() {
+      return TestUtils.renderIntoDocument(
+        new loop.roomViews.EmptyRoomView({
+          mozLoop: fakeMozLoop,
+          localRoomStore: store
+        }));
+    }
+
+    describe("#componentDidMount", function() {
+       it("should add #onCreationError using mozLoop.rooms.addCallback",
+         function() {
+
+           var testComponent = mountTestComponent();
+
+           sinon.assert.calledOnce(fakeMozLoop.rooms.addCallback);
+           sinon.assert.calledWithExactly(fakeMozLoop.rooms.addCallback,
+             fakeRoomId, "RoomCreationError", testComponent.onCreationError);
+         });
+    });
+
+    describe("#componentWillUnmount", function () {
+      it("should remove #onCreationError using mozLoop.rooms.addCallback",
+        function () {
+          var testComponent = mountTestComponent();
+
+          testComponent.componentWillUnmount();
+
+          sinon.assert.calledOnce(fakeMozLoop.rooms.removeCallback);
+          sinon.assert.calledWithExactly(fakeMozLoop.rooms.removeCallback,
+            fakeRoomId, "RoomCreationError", testComponent.onCreationError);
+        });
+      });
+
+    describe("#onCreationError", function() {
+      it("should log an error using console.error", function() {
+        fakeWindow.console = { error: sandbox.stub() };
+        var testComponent = mountTestComponent();
+
+        testComponent.onCreationError(new Error("fake error"));
+
+        sinon.assert.calledOnce(fakeWindow.console.error);
+      });
+    });
+
+    describe("#render", function() {
+      it("should set document.title to store.serverData.roomName",
+        function() {
+          var fakeRoomName = "Monkey";
+          store.setStoreState({serverData: {roomName: fakeRoomName},
+                               localRoomId: fakeRoomId});
+
+          mountTestComponent();
+
+          expect(fakeWindow.document.title).to.equal(fakeRoomName);
+        })
+    });
+
+  });
+});
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -1,14 +1,14 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 var expect = chai.expect;
 
-describe("loop.ConversationStore", function () {
+describe("loop.store.ConversationStore", function () {
   "use strict";
 
   var CALL_STATES = loop.store.CALL_STATES;
   var WS_STATES = loop.store.WS_STATES;
   var sharedActions = loop.shared.actions;
   var sharedUtils = loop.shared.utils;
   var sandbox, dispatcher, client, store, fakeSessionData, sdkDriver;
   var contact;
--- a/browser/components/loop/test/shared/index.html
+++ b/browser/components/loop/test/shared/index.html
@@ -38,28 +38,30 @@
   <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/validate.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
   <script src="../../content/shared/js/otSdkDriver.js"></script>
+  <script src="../../content/shared/js/localRoomStore.js"></script>
   <script src="../../content/shared/js/conversationStore.js"></script>
   <script src="../../content/shared/js/roomListStore.js"></script>
 
   <!-- Test scripts -->
   <script src="models_test.js"></script>
   <script src="mixins_test.js"></script>
   <script src="utils_test.js"></script>
   <script src="views_test.js"></script>
   <script src="websocket_test.js"></script>
   <script src="feedbackApiClient_test.js"></script>
   <script src="validate_test.js"></script>
   <script src="dispatcher_test.js"></script>
+  <script src="localRoomStore_test.js"></script>
   <script src="conversationStore_test.js"></script>
   <script src="otSdkDriver_test.js"></script>
   <script src="roomListStore_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
   </script>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/localRoomStore_test.js
@@ -0,0 +1,110 @@
+/* global chai */
+
+var expect = chai.expect;
+var sharedActions = loop.shared.actions;
+
+describe("loop.store.LocalRoomStore", function () {
+  "use strict";
+
+  var sandbox, dispatcher;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+    dispatcher = new loop.Dispatcher();
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("#constructor", function() {
+    it("should throw an error if the dispatcher is missing", function() {
+      expect(function() {
+        new loop.store.LocalRoomStore({mozLoop: {}});
+      }).to.Throw(/dispatcher/);
+    });
+
+    it("should throw an error if mozLoop is missing", function() {
+      expect(function() {
+        new loop.store.LocalRoomStore({dispatcher: dispatcher});
+      }).to.Throw(/mozLoop/);
+    });
+  });
+
+  describe("#setupEmptyRoom", function() {
+    var store, fakeMozLoop, fakeRoomId, fakeRoomName;
+
+    beforeEach(function() {
+      fakeRoomId = "337-ff-54";
+      fakeRoomName = "Monkeys";
+      fakeMozLoop = {
+        rooms: { getRoomData: sandbox.stub() }
+      };
+
+      store = new loop.store.LocalRoomStore(
+        {mozLoop: fakeMozLoop, dispatcher: dispatcher});
+      fakeMozLoop.rooms.getRoomData.
+        withArgs(fakeRoomId).
+        callsArgOnWith(1, // index of callback argument
+        store, // |this| to call it on
+        null, // args to call the callback with...
+        {roomName: fakeRoomName}
+      );
+    });
+
+    it("should trigger a change event", function(done) {
+      store.on("change", function() {
+        done();
+      });
+
+      dispatcher.dispatch(new sharedActions.SetupEmptyRoom(
+        {localRoomId: fakeRoomId}));
+    });
+
+    it("should set localRoomId on the store from the action data",
+      function(done) {
+
+        store.once("change", function () {
+          expect(store.getStoreState()).
+            to.have.property('localRoomId', fakeRoomId);
+          done();
+        });
+
+        dispatcher.dispatch(
+          new sharedActions.SetupEmptyRoom({localRoomId: fakeRoomId}));
+    });
+
+    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.SetupEmptyRoom({localRoomId: fakeRoomId}));
+      });
+
+    it("should set error on the store when getRoomData calls back an error",
+      function(done) {
+
+        var fakeError = new Error("fake error");
+        fakeMozLoop.rooms.getRoomData.
+          withArgs(fakeRoomId).
+          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.SetupEmptyRoom({localRoomId: fakeRoomId}));
+      });
+
+  });
+});
--- a/browser/components/loop/test/shared/mixins_test.js
+++ b/browser/components/loop/test/shared/mixins_test.js
@@ -33,16 +33,20 @@ describe("loop.shared.mixins", function(
         onDocumentHidden: onDocumentHiddenStub,
         onDocumentVisible: onDocumentVisibleStub,
         render: function() {
           return React.DOM.div();
         }
       });
     });
 
+    afterEach(function() {
+      loop.shared.mixins.setRootObject(window);
+    });
+
     function setupFakeVisibilityEventDispatcher(event) {
       loop.shared.mixins.setRootObject({
         document: {
           addEventListener: function(_, fn) {
             fn(event);
           },
           removeEventListener: sandbox.stub()
         }
--- a/browser/components/loop/ui/index.html
+++ b/browser/components/loop/ui/index.html
@@ -37,16 +37,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/validate.js"></script>
     <script src="../content/shared/js/dispatcher.js"></script>
     <script src="../content/shared/js/conversationStore.js"></script>
     <script src="../content/shared/js/roomListStore.js"></script>
+    <script src="../content/js/roomViews.js"></script>
     <script src="../content/js/conversationViews.js"></script>
     <script src="../content/js/client.js"></script>
     <script src="../standalone/content/js/webapp.js"></script>
     <script type="text/javascript;version=1.8" src="../content/js/contacts.js"></script>
     <script>
       if (!loop.contacts) {
         // For browsers that don't support ES6 without special flags (all but Fx
         // at the moment), we shim the contacts namespace with its most barebone