Bug 1094128 Convert the Loop Standalone controller app view to be based on the Flux style. r=nperriault a=loop-only
authorMark Banner <standard8@mozilla.com>
Wed, 05 Nov 2014 14:59:40 +0000
changeset 233799 13c96ad43e4da4140aa825fa15cbd46308709973
parent 233798 5b7c6ca135ebd39564fdbe2c38e3145309d9c373
child 233800 7e49e9a80a0166a8f988d0d3236bf35fbd2f1332
push id1
push usersledru@mozilla.com
push dateThu, 04 Dec 2014 17:57:20 +0000
reviewersnperriault, loop-only
bugs1094128
milestone35.0a2
Bug 1094128 Convert the Loop Standalone controller app view to be based on the Flux style. r=nperriault a=loop-only
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/mixins.js
browser/components/loop/standalone/content/index.html
browser/components/loop/standalone/content/js/standaloneAppStore.js
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/standalone/index.html
browser/components/loop/test/standalone/standaloneAppStore_test.js
browser/components/loop/test/standalone/webapp_test.js
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -33,30 +33,46 @@ loop.shared.actions = (function() {
     /**
      * Get the window data for the provided window id
      */
     GetWindowData: Action.define("getWindowData", {
       windowId: String
     }),
 
     /**
+     * Extract the token information and type for the standalone window
+     */
+    ExtractTokenInfo: Action.define("extractTokenInfo", {
+      windowPath: String
+    }),
+
+    /**
      * Used to pass round the window data so that stores can
      * record the appropriate data.
      */
     SetupWindowData: Action.define("setupWindowData", {
       windowId: String,
       type: String
 
       // Optional Items. There are other optional items typically sent
       // around with this action. They are for the setup of calls and rooms and
       // depend on the type. See LoopCalls and LoopRooms for the details of this
       // data.
     }),
 
     /**
+     * Used to fetch the data from the server for a room or call for the
+     * token.
+     */
+    FetchServerData: Action.define("fetchServerData", {
+      token: String,
+      windowType: String
+    }),
+
+    /**
      * 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.
--- a/browser/components/loop/content/shared/js/mixins.js
+++ b/browser/components/loop/content/shared/js/mixins.js
@@ -28,20 +28,16 @@ loop.shared.mixins = (function() {
 
   /**
    * window.location mixin. Handles changes in the call url.
    * Forces a reload of the page to ensure proper state of the webapp
    *
    * @type {Object}
    */
   var UrlHashChangeMixin = {
-    propTypes: {
-      onUrlHashChange: React.PropTypes.func.isRequired
-    },
-
     componentDidMount: function() {
       rootObject.addEventListener("hashchange", this.onUrlHashChange, false);
     },
 
     componentWillUnmount: function() {
       rootObject.removeEventListener("hashchange", this.onUrlHashChange, false);
     }
   };
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -37,18 +37,23 @@
 
     <!-- app scripts -->
     <script type="text/javascript" src="config.js"></script>
     <script type="text/javascript" src="shared/js/utils.js"></script>
     <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="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() {
         loop.webapp.init();
       }, false);
     </script>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/standalone/content/js/standaloneAppStore.js
@@ -0,0 +1,148 @@
+/* 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 || {};
+
+/**
+ * Manages the conversation window app controller view. Used to get
+ * the window data and store the window type.
+ */
+loop.store.StandaloneAppStore = (function() {
+  "use strict";
+
+  var sharedActions = loop.shared.actions;
+  var sharedUtils = loop.shared.utils;
+
+  var OLD_STYLE_CALL_REGEXP = /\#call\/(.*)/;
+  var NEW_STYLE_CALL_REGEXP = /\/c\/([\w\-]+)$/;
+  var ROOM_REGEXP = /\/([\w\-]+)$/;
+
+  /**
+   * Constructor
+   *
+   * @param {Object} options Options for the store. Should contain the dispatcher.
+   */
+  var StandaloneAppStore = function(options) {
+    if (!options.dispatcher) {
+      throw new Error("Missing option dispatcher");
+    }
+    if (!options.sdk) {
+      throw new Error("Missing option sdk");
+    }
+    if (!options.helper) {
+      throw new Error("Missing option helper");
+    }
+    if (!options.conversation) {
+      throw new Error("Missing option conversation");
+    }
+
+    this._dispatcher = options.dispatcher;
+    this._storeState = {};
+    this._sdk = options.sdk;
+    this._helper = options.helper;
+    this._conversation = options.conversation;
+
+    this._dispatcher.register(this, [
+      "extractTokenInfo"
+    ]);
+  };
+
+  StandaloneAppStore.prototype = _.extend({
+    /**
+     * Retrieves current store state.
+     *
+     * @return {Object}
+     */
+    getStoreState: function() {
+      return this._storeState;
+    },
+
+    /**
+     * Updates store states and trigger a "change" event.
+     *
+     * @param {Object} state The new store state.
+     */
+    setStoreState: function(state) {
+      this._storeState = state;
+      this.trigger("change");
+    },
+
+    _extractWindowDataFromPath: function(windowPath) {
+      var match;
+      var windowType = "home";
+
+      function extractId(path, regexp) {
+        var match = path.match(regexp);
+        if (match && match[1]) {
+          return match;
+        }
+        return null;
+      }
+
+      if (windowPath) {
+        // Is this a call url (the hash is a backwards-compatible url)?
+        match = extractId(windowPath, OLD_STYLE_CALL_REGEXP) ||
+                extractId(windowPath, NEW_STYLE_CALL_REGEXP);
+
+        if (match) {
+          windowType = "outgoing";
+        } else {
+          // Is this a room url?
+          match = extractId(windowPath, ROOM_REGEXP);
+
+          if (match) {
+            windowType = "room";
+          }
+        }
+      }
+      return [windowType, match && match[1] ? match[1] : null];
+    },
+
+    /**
+     * Handles the extract token info action - obtains the token information
+     * and its type; updates the store and notifies interested components.
+     *
+     * @param {sharedActions.GetWindowData} actionData The action data
+     */
+    extractTokenInfo: function(actionData) {
+      var windowType = "home";
+      var token;
+
+      // Check if we're on a supported device/platform.
+      if (this._helper.isIOS(navigator.platform)) {
+        windowType = "unsupportedDevice";
+      } else if (!this._sdk.checkSystemRequirements()) {
+        windowType = "unsupportedBrowser";
+      } else if (actionData.windowPath) {
+        // ES6 not used in standalone yet.
+        var result = this._extractWindowDataFromPath(actionData.windowPath);
+        windowType = result[0];
+        token = result[1];
+      }
+      // Else type is home.
+
+      if (token) {
+        this._conversation.set({loopToken: token});
+      }
+
+      this.setStoreState({
+        windowType: windowType
+      });
+
+      // If we've not got a window ID, don't dispatch the action, as we don't need
+      // it.
+      if (token) {
+        this._dispatcher.dispatch(new loop.shared.actions.FetchServerData({
+          token: token,
+          windowType: windowType
+        }));
+      }
+    }
+  }, Backbone.Events);
+
+  return StandaloneAppStore;
+})();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -0,0 +1,22 @@
+/** @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.standaloneRoomViews = (function() {
+  "use strict";
+
+  var StandaloneRoomView = React.createClass({displayName: 'StandaloneRoomView',
+    render: function() {
+      return (React.DOM.div(null, "Room"));
+    }
+  });
+
+  return {
+    StandaloneRoomView: StandaloneRoomView
+  };
+})();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -0,0 +1,22 @@
+/** @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.standaloneRoomViews = (function() {
+  "use strict";
+
+  var StandaloneRoomView = React.createClass({
+    render: function() {
+      return (<div>Room</div>);
+    }
+  });
+
+  return {
+    StandaloneRoomView: StandaloneRoomView
+  };
+})();
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -9,16 +9,17 @@
 
 var loop = loop || {};
 loop.webapp = (function($, _, OT, mozL10n) {
   "use strict";
 
   loop.config = loop.config || {};
   loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
 
+  var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedModels = loop.shared.models;
   var sharedViews = loop.shared.views;
   var sharedUtils = loop.shared.utils;
 
   var multiplexGum = loop.standaloneMedia.multiplexGum;
 
   /**
@@ -858,72 +859,96 @@ loop.webapp = (function($, _, OT, mozL10
 
   /**
    * Webapp Root View. This is the main, single, view that controls the display
    * of the webapp page.
    */
   var WebappRootView = React.createClass({displayName: 'WebappRootView',
 
     mixins: [sharedMixins.UrlHashChangeMixin,
-             sharedMixins.DocumentLocationMixin],
+             sharedMixins.DocumentLocationMixin,
+             Backbone.Events],
 
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
       conversation: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(sharedModels.ConversationModel),
         React.PropTypes.instanceOf(FxOSConversationModel)
       ]).isRequired,
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
-      feedbackApiClient: React.PropTypes.object.isRequired
+      feedbackApiClient: React.PropTypes.object.isRequired,
+
+      // XXX New types for flux style
+      standaloneAppStore: React.PropTypes.instanceOf(
+        loop.store.StandaloneAppStore).isRequired
     },
 
     getInitialState: function() {
-      return {
-        unsupportedDevice: this.props.helper.isIOS(navigator.platform),
-        unsupportedBrowser: !this.props.sdk.checkSystemRequirements(),
-      };
+      return this.props.standaloneAppStore.getStoreState();
+    },
+
+    componentWillMount: function() {
+      this.listenTo(this.props.standaloneAppStore, "change", function() {
+        this.setState(this.props.standaloneAppStore.getStoreState());
+      }, this);
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.standaloneAppStore);
     },
 
     onUrlHashChange: function() {
       this.locationReload();
     },
 
     render: function() {
-      if (this.state.unsupportedDevice) {
-        return UnsupportedDeviceView(null);
-      } else if (this.state.unsupportedBrowser) {
-        return UnsupportedBrowserView(null);
-      } else if (this.props.conversation.get("loopToken")) {
-        return (
-          OutgoingConversationView({
-             client: this.props.client, 
-             conversation: this.props.conversation, 
-             helper: this.props.helper, 
-             notifications: this.props.notifications, 
-             sdk: this.props.sdk, 
-             feedbackApiClient: this.props.feedbackApiClient}
-          )
-        );
-      } else {
-        return HomeView(null);
+      switch (this.state.windowType) {
+        case "unsupportedDevice": {
+          return UnsupportedDeviceView(null);
+        }
+        case "unsupportedBrowser": {
+          return UnsupportedBrowserView(null);
+        }
+        case "outgoing": {
+          return (
+            OutgoingConversationView({
+               client: this.props.client, 
+               conversation: this.props.conversation, 
+               helper: this.props.helper, 
+               notifications: this.props.notifications, 
+               sdk: this.props.sdk, 
+               feedbackApiClient: this.props.feedbackApiClient}
+            )
+          );
+        }
+        case "room": {
+          return loop.standaloneRoomViews.StandaloneRoomView(null);
+        }
+        case "home": {
+          return HomeView(null);
+        }
+        default: {
+          // The state hasn't been initialised yet, so don't display
+          // anything to avoid flicker.
+          return null;
+        }
       }
     }
   });
 
   /**
    * App initialization.
    */
   function init() {
     var helper = new sharedUtils.Helper();
-    var client = new loop.StandaloneClient({
-      baseServerUrl: loop.config.serverUrl
-    });
+
+    // Older non-flux based items.
     var notifications = new sharedModels.NotificationCollection();
     var conversation
     if (helper.isFirefoxOS(navigator.userAgent)) {
       conversation = new FxOSConversationModel();
     } else {
       conversation = new sharedModels.ConversationModel({}, {
         sdk: OT
       });
@@ -931,48 +956,49 @@ loop.webapp = (function($, _, OT, mozL10
 
     var feedbackApiClient = new loop.FeedbackAPIClient(
       loop.config.feedbackApiUrl, {
         product: loop.config.feedbackProductName,
         user_agent: navigator.userAgent,
         url: document.location.origin
       });
 
-    // Obtain the loopToken
-
-    var match;
+    // New flux items.
+    var dispatcher = new loop.Dispatcher();
+    var client = new loop.StandaloneClient({
+      baseServerUrl: loop.config.serverUrl
+    });
 
-    // locationHash supports the old format urls.
-    var locationData = helper.locationData();
-    if (locationData.hash) {
-      match = locationData.hash.match(/\#call\/(.*)/);
-    } else if (locationData.pathname) {
-      // Otherwise, we're expecting a url such as /c/<token> for calls.
-      match = locationData.pathname.match(/\/c\/([\w\-]+)/);
-    }
-    // XXX Supporting '/\/([\w\-]+)/' is for rooms which are to be implemented
-    // in bug 1074701.
-
-    if (match && match[1]) {
-      conversation.set({loopToken: match[1]});
-    }
+    var standaloneAppStore = new loop.store.StandaloneAppStore({
+      conversation: conversation,
+      dispatcher: dispatcher,
+      helper: helper,
+      sdk: OT
+    });
 
     React.renderComponent(WebappRootView({
       client: client, 
       conversation: conversation, 
       helper: helper, 
       notifications: notifications, 
       sdk: OT, 
-      feedbackApiClient: feedbackApiClient}
+      feedbackApiClient: feedbackApiClient, 
+      standaloneAppStore: standaloneAppStore}
     ), 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({
+      // We pass the hash or the pathname - the hash was used for the original
+      // urls, the pathname for later ones.
+      windowPath: helper.locationData().hash || helper.locationData().pathname
+    }));
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
     FailedConversationView: FailedConversationView,
     OutgoingConversationView: OutgoingConversationView,
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -9,16 +9,17 @@
 
 var loop = loop || {};
 loop.webapp = (function($, _, OT, mozL10n) {
   "use strict";
 
   loop.config = loop.config || {};
   loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
 
+  var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedModels = loop.shared.models;
   var sharedViews = loop.shared.views;
   var sharedUtils = loop.shared.utils;
 
   var multiplexGum = loop.standaloneMedia.multiplexGum;
 
   /**
@@ -858,72 +859,96 @@ loop.webapp = (function($, _, OT, mozL10
 
   /**
    * Webapp Root View. This is the main, single, view that controls the display
    * of the webapp page.
    */
   var WebappRootView = React.createClass({
 
     mixins: [sharedMixins.UrlHashChangeMixin,
-             sharedMixins.DocumentLocationMixin],
+             sharedMixins.DocumentLocationMixin,
+             Backbone.Events],
 
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
       conversation: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(sharedModels.ConversationModel),
         React.PropTypes.instanceOf(FxOSConversationModel)
       ]).isRequired,
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
-      feedbackApiClient: React.PropTypes.object.isRequired
+      feedbackApiClient: React.PropTypes.object.isRequired,
+
+      // XXX New types for flux style
+      standaloneAppStore: React.PropTypes.instanceOf(
+        loop.store.StandaloneAppStore).isRequired
     },
 
     getInitialState: function() {
-      return {
-        unsupportedDevice: this.props.helper.isIOS(navigator.platform),
-        unsupportedBrowser: !this.props.sdk.checkSystemRequirements(),
-      };
+      return this.props.standaloneAppStore.getStoreState();
+    },
+
+    componentWillMount: function() {
+      this.listenTo(this.props.standaloneAppStore, "change", function() {
+        this.setState(this.props.standaloneAppStore.getStoreState());
+      }, this);
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.standaloneAppStore);
     },
 
     onUrlHashChange: function() {
       this.locationReload();
     },
 
     render: function() {
-      if (this.state.unsupportedDevice) {
-        return <UnsupportedDeviceView />;
-      } else if (this.state.unsupportedBrowser) {
-        return <UnsupportedBrowserView />;
-      } else if (this.props.conversation.get("loopToken")) {
-        return (
-          <OutgoingConversationView
-             client={this.props.client}
-             conversation={this.props.conversation}
-             helper={this.props.helper}
-             notifications={this.props.notifications}
-             sdk={this.props.sdk}
-             feedbackApiClient={this.props.feedbackApiClient}
-          />
-        );
-      } else {
-        return <HomeView />;
+      switch (this.state.windowType) {
+        case "unsupportedDevice": {
+          return <UnsupportedDeviceView />;
+        }
+        case "unsupportedBrowser": {
+          return <UnsupportedBrowserView />;
+        }
+        case "outgoing": {
+          return (
+            <OutgoingConversationView
+               client={this.props.client}
+               conversation={this.props.conversation}
+               helper={this.props.helper}
+               notifications={this.props.notifications}
+               sdk={this.props.sdk}
+               feedbackApiClient={this.props.feedbackApiClient}
+            />
+          );
+        }
+        case "room": {
+          return <loop.standaloneRoomViews.StandaloneRoomView/>;
+        }
+        case "home": {
+          return <HomeView />;
+        }
+        default: {
+          // The state hasn't been initialised yet, so don't display
+          // anything to avoid flicker.
+          return null;
+        }
       }
     }
   });
 
   /**
    * App initialization.
    */
   function init() {
     var helper = new sharedUtils.Helper();
-    var client = new loop.StandaloneClient({
-      baseServerUrl: loop.config.serverUrl
-    });
+
+    // Older non-flux based items.
     var notifications = new sharedModels.NotificationCollection();
     var conversation
     if (helper.isFirefoxOS(navigator.userAgent)) {
       conversation = new FxOSConversationModel();
     } else {
       conversation = new sharedModels.ConversationModel({}, {
         sdk: OT
       });
@@ -931,48 +956,49 @@ loop.webapp = (function($, _, OT, mozL10
 
     var feedbackApiClient = new loop.FeedbackAPIClient(
       loop.config.feedbackApiUrl, {
         product: loop.config.feedbackProductName,
         user_agent: navigator.userAgent,
         url: document.location.origin
       });
 
-    // Obtain the loopToken
-
-    var match;
+    // New flux items.
+    var dispatcher = new loop.Dispatcher();
+    var client = new loop.StandaloneClient({
+      baseServerUrl: loop.config.serverUrl
+    });
 
-    // locationHash supports the old format urls.
-    var locationData = helper.locationData();
-    if (locationData.hash) {
-      match = locationData.hash.match(/\#call\/(.*)/);
-    } else if (locationData.pathname) {
-      // Otherwise, we're expecting a url such as /c/<token> for calls.
-      match = locationData.pathname.match(/\/c\/([\w\-]+)/);
-    }
-    // XXX Supporting '/\/([\w\-]+)/' is for rooms which are to be implemented
-    // in bug 1074701.
-
-    if (match && match[1]) {
-      conversation.set({loopToken: match[1]});
-    }
+    var standaloneAppStore = new loop.store.StandaloneAppStore({
+      conversation: conversation,
+      dispatcher: dispatcher,
+      helper: helper,
+      sdk: OT
+    });
 
     React.renderComponent(<WebappRootView
       client={client}
       conversation={conversation}
       helper={helper}
       notifications={notifications}
       sdk={OT}
       feedbackApiClient={feedbackApiClient}
+      standaloneAppStore={standaloneAppStore}
     />, 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({
+      // We pass the hash or the pathname - the hash was used for the original
+      // urls, the pathname for later ones.
+      windowPath: helper.locationData().hash || helper.locationData().pathname
+    }));
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
     FailedConversationView: FailedConversationView,
     OutgoingConversationView: OutgoingConversationView,
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -32,21 +32,27 @@
   </script>
   <!-- App scripts -->
   <script src="../../content/shared/js/utils.js"></script>
   <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="../../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>
   <script src="webapp_test.js"></script>
   <script src="multiplexGum_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
 </script>
 </body>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/standalone/standaloneAppStore_test.js
@@ -0,0 +1,246 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var expect = chai.expect;
+
+describe("loop.store.StandaloneAppStore", function () {
+  var sharedActions = loop.shared.actions;
+  var sharedUtils = loop.shared.utils;
+  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.StandaloneAppStore({
+          sdk: {},
+          helper: {},
+          conversation: {}
+        });
+      }).to.Throw(/dispatcher/);
+    });
+
+    it("should throw an error if sdk is missing", function() {
+      expect(function() {
+        new loop.store.StandaloneAppStore({
+          dispatcher: dispatcher,
+          helper: {},
+          conversation: {}
+        });
+      }).to.Throw(/sdk/);
+    });
+
+    it("should throw an error if helper is missing", function() {
+      expect(function() {
+        new loop.store.StandaloneAppStore({
+          dispatcher: dispatcher,
+          sdk: {},
+          conversation: {}
+        });
+      }).to.Throw(/helper/);
+    });
+
+    it("should throw an error if conversation is missing", function() {
+      expect(function() {
+        new loop.store.StandaloneAppStore({
+          dispatcher: dispatcher,
+          sdk: {},
+          helper: {}
+        });
+      }).to.Throw(/conversation/);
+    });
+  });
+
+  describe("#extractTokenInfo", function() {
+    var store, fakeGetWindowData, fakeSdk, fakeConversation, helper;
+
+    beforeEach(function() {
+      fakeGetWindowData = {
+        windowPath: ""
+      };
+
+      helper = new sharedUtils.Helper();
+      sandbox.stub(helper, "isIOS").returns(false);
+
+      fakeSdk = {
+        checkSystemRequirements: sinon.stub().returns(true)
+      };
+
+      fakeConversation = {
+        set: sinon.spy()
+      };
+
+      sandbox.stub(dispatcher, "dispatch");
+
+      store = new loop.store.StandaloneAppStore({
+        dispatcher: dispatcher,
+        sdk: fakeSdk,
+        helper: helper,
+        conversation: fakeConversation
+      });
+    });
+
+    it("should set windowType to `unsupportedDevice` for IOS", function() {
+      // The stub should return true for this test.
+      helper.isIOS.returns(true);
+
+      store.extractTokenInfo(
+        new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+      expect(store.getStoreState()).eql({
+        windowType: "unsupportedDevice"
+      });
+    });
+
+    it("should set windowType to `unsupportedBrowser` for browsers the sdk does not support",
+      function() {
+        // The stub should return false for this test.
+        fakeSdk.checkSystemRequirements.returns(false);
+
+        store.extractTokenInfo(
+          new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+        expect(store.getStoreState()).eql({
+          windowType: "unsupportedBrowser"
+        });
+      });
+
+    it("should set windowType to `outgoing` for old style call hashes", function() {
+      fakeGetWindowData.windowPath = "#call/faketoken";
+
+      store.extractTokenInfo(
+        new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+      expect(store.getStoreState()).eql({
+        windowType: "outgoing"
+      });
+    });
+
+    it("should set windowType to `outgoing` for new style call paths", function() {
+      fakeGetWindowData.windowPath = "/c/fakecalltoken";
+
+      store.extractTokenInfo(
+        new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+      expect(store.getStoreState()).eql({
+        windowType: "outgoing"
+      });
+    });
+
+    it("should set windowType to `room` for room paths", function() {
+      fakeGetWindowData.windowPath = "/fakeroomtoken";
+
+      store.extractTokenInfo(
+        new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+      expect(store.getStoreState()).eql({
+        windowType: "room"
+      });
+    });
+
+    it("should set windowType to `home` for unknown paths", function() {
+      fakeGetWindowData.windowPath = "/";
+
+      store.extractTokenInfo(
+        new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+      expect(store.getStoreState()).eql({
+        windowType: "home"
+      });
+    });
+
+    it("should set the loopToken on the conversation for old style call hashes",
+      function() {
+        fakeGetWindowData.windowPath = "#call/faketoken";
+
+        store.extractTokenInfo(
+          new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+        sinon.assert.calledOnce(fakeConversation.set);
+        sinon.assert.calledWithExactly(fakeConversation.set, {
+          loopToken: "faketoken"
+        });
+      });
+
+    it("should set the loopToken on the conversation for new style call paths",
+      function() {
+        fakeGetWindowData.windowPath = "/c/fakecalltoken";
+
+        store.extractTokenInfo(
+          new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+        sinon.assert.calledOnce(fakeConversation.set);
+        sinon.assert.calledWithExactly(fakeConversation.set, {
+          loopToken: "fakecalltoken"
+        });
+      });
+
+    it("should set the loopToken on the conversation for room paths",
+      function() {
+        fakeGetWindowData.windowPath = "/c/fakeroomtoken";
+
+        store.extractTokenInfo(
+          new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+        sinon.assert.calledOnce(fakeConversation.set);
+        sinon.assert.calledWithExactly(fakeConversation.set, {
+          loopToken: "fakeroomtoken"
+        });
+      });
+
+    it("should dispatch a SetupWindowData action for old style call hashes",
+      function() {
+        fakeGetWindowData.windowPath = "#call/faketoken";
+
+        store.extractTokenInfo(
+          new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.FetchServerData({
+            windowType: "outgoing",
+            token: "faketoken"
+          }));
+      });
+
+    it("should set the loopToken on the conversation for new style call paths",
+      function() {
+        fakeGetWindowData.windowPath = "/c/fakecalltoken";
+
+        store.extractTokenInfo(
+          new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.FetchServerData({
+            windowType: "outgoing",
+            token: "fakecalltoken"
+          }));
+      });
+
+    it("should set the loopToken on the conversation for room paths",
+      function() {
+        fakeGetWindowData.windowPath = "/c/fakeroomtoken";
+
+        store.extractTokenInfo(
+          new sharedActions.ExtractTokenInfo(fakeGetWindowData));
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.FetchServerData({
+            windowType: "outgoing",
+            token: "fakeroomtoken"
+          }));
+      });
+
+  });
+
+});
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -5,16 +5,17 @@
 /* global loop, sinon */
 
 var expect = chai.expect;
 var TestUtils = React.addons.TestUtils;
 
 describe("loop.webapp", function() {
   "use strict";
 
+  var sharedActions = loop.shared.actions;
   var sharedModels = loop.shared.models,
       sharedViews = loop.shared.views,
       sharedUtils = loop.shared.utils,
       standaloneMedia = loop.standaloneMedia,
       sandbox,
       notifications,
       feedbackApiClient,
       stubGetPermsAndCacheMedia;
@@ -30,63 +31,63 @@ describe("loop.webapp", function() {
       loop.standaloneMedia._MultiplexGum.prototype, "getPermsAndCacheMedia");
   });
 
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("#init", function() {
-    var conversationSetStub;
-
     beforeEach(function() {
       sandbox.stub(React, "renderComponent");
       loop.config.feedbackApiUrl = "http://fake.invalid";
-      conversationSetStub =
-        sandbox.stub(sharedModels.ConversationModel.prototype, "set");
+      sandbox.stub(loop.Dispatcher.prototype, "dispatch");
     });
 
     it("should create the WebappRootView", function() {
       loop.webapp.init();
 
       sinon.assert.calledOnce(React.renderComponent);
       sinon.assert.calledWith(React.renderComponent,
         sinon.match(function(value) {
           return TestUtils.isDescriptorOfType(value,
             loop.webapp.WebappRootView);
       }));
     });
 
-    it("should set the loopToken on the conversation for old-style call urls",
-      function() {
-        sandbox.stub(sharedUtils.Helper.prototype,
-          "locationData").returns({
-            hash: "#call/fake-Token",
-            pathname: "/"
-          });
-
-        loop.webapp.init();
-
-        sinon.assert.called(conversationSetStub);
-        sinon.assert.calledWithExactly(conversationSetStub, {loopToken: "fake-Token"});
+    it("should dispatch a ExtractTokenInfo action with the hash", function() {
+      sandbox.stub(loop.shared.utils.Helper.prototype, "locationData").returns({
+        hash: "#call/faketoken",
+        pathname: "invalid"
       });
 
-    it("should set the loopToken on the conversation for new-style call urls",
+      loop.webapp.init();
+
+      sinon.assert.calledOnce(loop.Dispatcher.prototype.dispatch);
+      sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch,
+        new sharedActions.ExtractTokenInfo({
+          windowPath: "#call/faketoken"
+        }));
+    });
+
+    it("should dispatch a ExtractTokenInfo action with the path if there is no hash",
       function() {
-        sandbox.stub(sharedUtils.Helper.prototype,
-          "locationData").returns({
-            hash: "",
-            pathname: "/c/abc123-_Tes"
-          });
+        sandbox.stub(loop.shared.utils.Helper.prototype, "locationData").returns({
+          hash: "",
+          pathname: "/c/faketoken"
+        });
+
+      loop.webapp.init();
 
-        loop.webapp.init();
-
-        sinon.assert.called(conversationSetStub);
-        sinon.assert.calledWithExactly(conversationSetStub, {loopToken: "abc123-_Tes"});
-      });
+      sinon.assert.calledOnce(loop.Dispatcher.prototype.dispatch);
+      sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch,
+        new sharedActions.ExtractTokenInfo({
+          windowPath: "/c/faketoken"
+        }));
+    });
   });
 
   describe("OutgoingConversationView", function() {
     var ocView, conversation, client;
 
     function mountTestComponent(props) {
       return TestUtils.renderIntoDocument(
         loop.webapp.OutgoingConversationView(props));
@@ -539,80 +540,98 @@ describe("loop.webapp", function() {
         sinon.assert.calledWithExactly(window.Audio,
                                        "shared/sounds/failure.ogg");
         expect(fakeAudio.loop).to.equal(false);
       });
     });
   });
 
   describe("WebappRootView", function() {
-    var helper, sdk, conversationModel, client, props;
+    var helper, sdk, conversationModel, client, props, standaloneAppStore;
+    var dispatcher;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.webapp.WebappRootView({
         client: client,
         helper: helper,
         notifications: notifications,
         sdk: sdk,
         conversation: conversationModel,
         feedbackApiClient: feedbackApiClient,
-        onUrlHashChange: sandbox.stub()
+        standaloneAppStore: standaloneAppStore
       }));
     }
 
     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();
+      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.
       sandbox.stub(client, "requestCallUrlInfo");
     });
 
-    it("should mount the unsupportedDevice view if the device is running iOS",
+    it("should display the UnsupportedDeviceView for `unsupportedDevice` window type",
       function() {
-        sandbox.stub(helper, "isIOS").returns(true);
+        standaloneAppStore.setStoreState({windowType: "unsupportedDevice"});
 
         var webappRootView = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(webappRootView,
           loop.webapp.UnsupportedDeviceView);
       });
 
-    it("should mount the unsupportedBrowser view if the sdk detects " +
-      "the browser is unsupported", function() {
-        sdk.checkSystemRequirements = function() {
-          return false;
-        };
+    it("should display the UnsupportedBrowserView for `unsupportedBrowser` window type",
+      function() {
+        standaloneAppStore.setStoreState({windowType: "unsupportedBrowser"});
 
         var webappRootView = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(webappRootView,
           loop.webapp.UnsupportedBrowserView);
       });
 
-    it("should mount the OutgoingConversationView view if there is a loopToken",
+    it("should display the OutgoingConversationView for `outgoing` window type",
       function() {
-        conversationModel.set("loopToken", "fakeToken");
+        standaloneAppStore.setStoreState({windowType: "outgoing"});
 
         var webappRootView = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(webappRootView,
           loop.webapp.OutgoingConversationView);
       });
 
-    it("should mount the Home view there is no loopToken", function() {
+    it("should display the StandaloneRoomView for `room` window type",
+      function() {
+        standaloneAppStore.setStoreState({windowType: "room"});
+
+        var webappRootView = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(webappRootView,
+          loop.standaloneRoomViews.StandaloneRoomView);
+      });
+
+    it("should display the HomeView for `home` window type", function() {
+        standaloneAppStore.setStoreState({windowType: "home"});
+
         var webappRootView = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(webappRootView,
           loop.webapp.HomeView);
     });
   });
 
   describe("HomeView", function() {