Bug 1074686 - Test/impl EmptyRoomView, store, and actions, r=Standard8
authorDan Mosedale <dmose@meer.net>
Tue, 14 Oct 2014 15:22:35 -0700
changeset 210413 fdf1c444300836f3fe27e41c65a55fe618852088
parent 210412 cb55300789e1dd8aea8e61a6d7e22c64299f06ed
child 210414 b072d6d6d730fcfae3e4a9855686c43723dd137b
push id27653
push userryanvm@gmail.com
push dateWed, 15 Oct 2014 17:59:08 +0000
treeherdermozilla-central@7de522bd9785 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs1074686
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 1074686 - Test/impl EmptyRoomView, store, and actions, r=Standard8
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
     },
@@ -475,40 +478,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, 
@@ -564,53 +579,72 @@ 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,
     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
     },
@@ -475,40 +478,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}
@@ -564,53 +579,72 @@ 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,
     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
@@ -120,11 +120,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 sandbox, oldTitle, view, dispatcher, contact;
 
   var CALL_STATES = loop.store.CALL_STATES;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
     oldTitle = document.title;
--- 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