Bug 972017 Part 2 - Set up actions and a dispatcher and start to handle obtaining call data for outgoing Loop calls from the desktop client. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Tue, 30 Sep 2014 20:44:05 +0100
changeset 208108 5d2c69ebb3211c97adcd6f90476ea1e37da1cd66
parent 208107 326b3f37e469dd8a6b477e8b137d3ea9b95c6683
child 208109 e1afb3399612da4a9f99e013083f1132cafd88c2
push id27576
push usercbook@mozilla.com
push dateWed, 01 Oct 2014 13:00:38 +0000
treeherderautoland@1a550125928f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs972017
milestone35.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 972017 Part 2 - Set up actions and a dispatcher and start to handle obtaining call data for outgoing Loop calls from the desktop client. r=mikedeboer
browser/app/profile/firefox.js
browser/components/loop/content/conversation.html
browser/components/loop/content/js/client.js
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/conversationViews.js
browser/components/loop/content/js/conversationViews.jsx
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/content/shared/js/dispatcher.js
browser/components/loop/content/shared/js/utils.js
browser/components/loop/content/shared/js/validate.js
browser/components/loop/jar.mn
browser/components/loop/test/desktop-local/client_test.js
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/shared/conversationStore_test.js
browser/components/loop/test/shared/dispatcher_test.js
browser/components/loop/test/shared/index.html
browser/components/loop/test/shared/validate_test.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1608,16 +1608,17 @@ pref("loop.legal.ToS_url", "https://call
 pref("loop.legal.privacy_url", "https://www.mozilla.org/privacy/");
 pref("loop.do_not_disturb", false);
 pref("loop.ringtone", "chrome://browser/content/loop/shared/sounds/Firefox-Long.ogg");
 pref("loop.retry_delay.start", 60000);
 pref("loop.retry_delay.limit", 300000);
 pref("loop.feedback.baseUrl", "https://input.mozilla.org/api/v1/feedback");
 pref("loop.feedback.product", "Loop");
 pref("loop.debug.loglevel", "Error");
+pref("loop.debug.dispatcher", false);
 pref("loop.debug.websocket", false);
 pref("loop.debug.sdk", false);
 
 // serverURL to be assigned by services team
 pref("services.push.serverURL", "wss://push.services.mozilla.com/");
 
 pref("social.sidebar.unload_timeout_ms", 10000);
 
--- a/browser/components/loop/content/conversation.html
+++ b/browser/components/loop/content/conversation.html
@@ -25,16 +25,19 @@
     <script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
 
     <script type="text/javascript" src="loop/shared/js/utils.js"></script>
     <script type="text/javascript" src="loop/shared/js/models.js"></script>
     <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/conversationStore.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/conversation.js"></script>
   </body>
 </html>
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -10,16 +10,22 @@ loop.Client = (function($) {
   "use strict";
 
   // The expected properties to be returned from the POST /call-url/ request.
   var expectedCallUrlProperties = ["callUrl", "expiresAt"];
 
   // The expected properties to be returned from the GET /calls request.
   var expectedCallProperties = ["calls"];
 
+  // THe expected properties to be returned from the POST /calls request.
+  var expectedPostCallProperties = [
+    "apiKey", "callId", "progressURL",
+    "sessionId", "sessionToken", "websocketToken"
+  ];
+
   /**
    * Loop server client.
    *
    * @param {Object} settings Settings object.
    */
   function Client(settings) {
     if (!settings) {
       settings = {};
@@ -205,16 +211,54 @@ loop.Client = (function($) {
           return;
         }
 
         this._requestCallUrlInternal(nickname, cb);
       }.bind(this));
     },
 
     /**
+     * Sets up an outgoing call, getting the relevant data from the server.
+     *
+     * Callback parameters:
+     * - err null on successful registration, non-null otherwise.
+     * - result an object of the obtained data for starting the call, if successful
+     *
+     * @param {Array} calleeIds an array of emails and phone numbers.
+     * @param {String} callType the type of call.
+     * @param {Function} cb Callback(err, result)
+     */
+    setupOutgoingCall: function(calleeIds, callType, cb) {
+      this.mozLoop.hawkRequest(this.mozLoop.LOOP_SESSION_TYPE.FXA,
+        "/calls", "POST", {
+          calleeId: calleeIds,
+          callType: callType
+        },
+        function (err, responseText) {
+          if (err) {
+            this._failureHandler(cb, err);
+            return;
+          }
+
+          try {
+            var postData = JSON.parse(responseText);
+
+            var outgoingCallData = this._validate(postData,
+              expectedPostCallProperties);
+
+            cb(null, outgoingCallData);
+          } catch (err) {
+            console.log("Error requesting call info", err);
+            cb(err);
+          }
+        }.bind(this)
+      );
+    },
+
+    /**
      * Adds a value to a telemetry histogram, ignoring errors.
      *
      * @param  {string}  histogramId Name of the telemetry histogram to update.
      * @param  {integer} value       Value to add to the histogram.
      */
     _telemetryAdd: function(histogramId, value) {
       try {
         this.mozLoop.telemetryAdd(histogramId, value);
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -497,24 +497,35 @@ loop.conversation = (function(mozL10n) {
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
 
       // XXX New types for OutgoingConversationView
-      store: React.PropTypes.instanceOf(loop.ConversationStore).isRequired
+      store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired
     },
 
     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() {
+      // 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}
         ));
       }
 
       return (IncomingConversationView({
         client: this.props.client, 
@@ -540,56 +551,63 @@ loop.conversation = (function(mozL10n) {
         callback(null, navigator.mozLoop.getLoopCharPref("ot.guid"));
       },
       set: function(guid, callback) {
         navigator.mozLoop.setLoopCharPref("ot.guid", guid);
         callback(null);
       }
     });
 
-    var conversationStore = new loop.ConversationStore();
+    var dispatcher = new loop.Dispatcher();
+    var client = new loop.Client();
+    var conversationStore = new loop.store.ConversationStore({}, {
+      client: client,
+      dispatcher: dispatcher
+    });
 
     // XXX For now key this on the pref, but this should really be
     // set by the information from the mozLoop API when we can get it (bug 1072323).
     var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail");
-    if (outgoingEmail) {
-      conversationStore.set("outgoing", true);
-      conversationStore.set("calleeId", outgoingEmail);
-    }
 
     // XXX Old class creation for the incoming conversation view, whilst
     // we transition across (bug 1072323).
-    var client = new loop.Client();
     var conversation = new sharedModels.ConversationModel(
       {},                // Model attributes
       {sdk: window.OT}   // Model dependencies
     );
     var notifications = new sharedModels.NotificationCollection();
 
     // Obtain the callId and pass it through
     var helper = new loop.shared.utils.Helper();
     var locationHash = helper.locationHash();
+    var callId;
     if (locationHash) {
-      conversation.set("callId", locationHash.match(/\#incoming\/(.*)/)[1]);
+      callId = locationHash.match(/\#incoming\/(.*)/)[1]
+      conversation.set("callId", callId);
     }
 
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
       navigator.mozLoop.releaseCallData(conversation.get("callId"));
     });
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
 
     React.renderComponent(ConversationControllerView({
       store: conversationStore, 
       client: client, 
       conversation: conversation, 
       notifications: notifications, 
       sdk: window.OT}
     ), document.querySelector('#main'));
+
+    dispatcher.dispatch(new loop.shared.actions.GatherCallData({
+      callId: callId,
+      calleeId: outgoingEmail
+    }));
   }
 
   return {
     ConversationControllerView: ConversationControllerView,
     IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     init: init
   };
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -497,24 +497,35 @@ loop.conversation = (function(mozL10n) {
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
 
       // XXX New types for OutgoingConversationView
-      store: React.PropTypes.instanceOf(loop.ConversationStore).isRequired
+      store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired
     },
 
     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() {
+      // 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}
         />);
       }
 
       return (<IncomingConversationView
         client={this.props.client}
@@ -540,56 +551,63 @@ loop.conversation = (function(mozL10n) {
         callback(null, navigator.mozLoop.getLoopCharPref("ot.guid"));
       },
       set: function(guid, callback) {
         navigator.mozLoop.setLoopCharPref("ot.guid", guid);
         callback(null);
       }
     });
 
-    var conversationStore = new loop.ConversationStore();
+    var dispatcher = new loop.Dispatcher();
+    var client = new loop.Client();
+    var conversationStore = new loop.store.ConversationStore({}, {
+      client: client,
+      dispatcher: dispatcher
+    });
 
     // XXX For now key this on the pref, but this should really be
     // set by the information from the mozLoop API when we can get it (bug 1072323).
     var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail");
-    if (outgoingEmail) {
-      conversationStore.set("outgoing", true);
-      conversationStore.set("calleeId", outgoingEmail);
-    }
 
     // XXX Old class creation for the incoming conversation view, whilst
     // we transition across (bug 1072323).
-    var client = new loop.Client();
     var conversation = new sharedModels.ConversationModel(
       {},                // Model attributes
       {sdk: window.OT}   // Model dependencies
     );
     var notifications = new sharedModels.NotificationCollection();
 
     // Obtain the callId and pass it through
     var helper = new loop.shared.utils.Helper();
     var locationHash = helper.locationHash();
+    var callId;
     if (locationHash) {
-      conversation.set("callId", locationHash.match(/\#incoming\/(.*)/)[1]);
+      callId = locationHash.match(/\#incoming\/(.*)/)[1]
+      conversation.set("callId", callId);
     }
 
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
       navigator.mozLoop.releaseCallData(conversation.get("callId"));
     });
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
 
     React.renderComponent(<ConversationControllerView
       store={conversationStore}
       client={client}
       conversation={conversation}
       notifications={notifications}
       sdk={window.OT}
     />, document.querySelector('#main'));
+
+    dispatcher.dispatch(new loop.shared.actions.GatherCallData({
+      callId: callId,
+      calleeId: outgoingEmail
+    }));
   }
 
   return {
     ConversationControllerView: ConversationControllerView,
     IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     init: init
   };
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -4,16 +4,18 @@
  * 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.conversationViews = (function(mozL10n) {
 
+  var CALL_STATES = loop.store.CALL_STATES;
+
   /**
    * Displays details of the incoming/outgoing conversation
    * (name, link, audio/video type etc).
    *
    * Allows the view to be extended with different buttons and progress
    * via children properties.
    */
   var ConversationDetailView = React.createClass({displayName: 'ConversationDetailView',
@@ -40,18 +42,18 @@ loop.conversationViews = (function(mozL1
   var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
     propTypes: {
       callState: React.PropTypes.string,
       calleeId: React.PropTypes.string,
     },
 
     render: function() {
       var pendingStateString;
-      if (this.props.callState === "ringing") {
-        pendingStateString = mozL10n.get("call_progress_pending_description");
+      if (this.props.callState === CALL_STATES.ALERTING) {
+        pendingStateString = mozL10n.get("call_progress_ringing_description");
       } else {
         pendingStateString = mozL10n.get("call_progress_connecting_description");
       }
 
       return (
         ConversationDetailView({calleeId: this.props.calleeId}, 
 
           React.DOM.p({className: "btn-label"}, pendingStateString), 
@@ -65,36 +67,60 @@ loop.conversationViews = (function(mozL1
           )
 
         )
       );
     }
   });
 
   /**
+   * Call failed view. Displayed when a call fails.
+   */
+  var CallFailedView = React.createClass({displayName: 'CallFailedView',
+    render: function() {
+      return (
+        React.DOM.div({className: "call-window"}, 
+          React.DOM.h2(null, mozL10n.get("generic_failure_title"))
+        )
+      );
+    }
+  });
+
+  /**
    * Master View Controller for outgoing calls. This manages
    * the different views that need displaying.
    */
   var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
     propTypes: {
       store: React.PropTypes.instanceOf(
-        loop.ConversationStore).isRequired
+        loop.store.ConversationStore).isRequired
     },
 
     getInitialState: function() {
       return this.props.store.attributes;
     },
 
+    componentWillMount: function() {
+      this.props.store.on("change", function() {
+        this.setState(this.props.store.attributes);
+      }, this);
+    },
+
     render: function() {
+      if (this.state.callState === CALL_STATES.TERMINATED) {
+        return (CallFailedView(null));
+      }
+
       return (PendingConversationView({
         callState: this.state.callState, 
         calleeId: this.state.calleeId}
       ))
     }
   });
 
   return {
     PendingConversationView: PendingConversationView,
     ConversationDetailView: ConversationDetailView,
+    CallFailedView: CallFailedView,
     OutgoingConversationView: OutgoingConversationView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -4,16 +4,18 @@
  * 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.conversationViews = (function(mozL10n) {
 
+  var CALL_STATES = loop.store.CALL_STATES;
+
   /**
    * Displays details of the incoming/outgoing conversation
    * (name, link, audio/video type etc).
    *
    * Allows the view to be extended with different buttons and progress
    * via children properties.
    */
   var ConversationDetailView = React.createClass({
@@ -40,18 +42,18 @@ loop.conversationViews = (function(mozL1
   var PendingConversationView = React.createClass({
     propTypes: {
       callState: React.PropTypes.string,
       calleeId: React.PropTypes.string,
     },
 
     render: function() {
       var pendingStateString;
-      if (this.props.callState === "ringing") {
-        pendingStateString = mozL10n.get("call_progress_pending_description");
+      if (this.props.callState === CALL_STATES.ALERTING) {
+        pendingStateString = mozL10n.get("call_progress_ringing_description");
       } else {
         pendingStateString = mozL10n.get("call_progress_connecting_description");
       }
 
       return (
         <ConversationDetailView calleeId={this.props.calleeId}>
 
           <p className="btn-label">{pendingStateString}</p>
@@ -65,36 +67,60 @@ loop.conversationViews = (function(mozL1
           </div>
 
         </ConversationDetailView>
       );
     }
   });
 
   /**
+   * Call failed view. Displayed when a call fails.
+   */
+  var CallFailedView = React.createClass({
+    render: function() {
+      return (
+        <div className="call-window">
+          <h2>{mozL10n.get("generic_failure_title")}</h2>
+        </div>
+      );
+    }
+  });
+
+  /**
    * Master View Controller for outgoing calls. This manages
    * the different views that need displaying.
    */
   var OutgoingConversationView = React.createClass({
     propTypes: {
       store: React.PropTypes.instanceOf(
-        loop.ConversationStore).isRequired
+        loop.store.ConversationStore).isRequired
     },
 
     getInitialState: function() {
       return this.props.store.attributes;
     },
 
+    componentWillMount: function() {
+      this.props.store.on("change", function() {
+        this.setState(this.props.store.attributes);
+      }, this);
+    },
+
     render: function() {
+      if (this.state.callState === CALL_STATES.TERMINATED) {
+        return (<CallFailedView />);
+      }
+
       return (<PendingConversationView
         callState={this.state.callState}
         calleeId={this.state.calleeId}
       />)
     }
   });
 
   return {
     PendingConversationView: PendingConversationView,
     ConversationDetailView: ConversationDetailView,
+    CallFailedView: CallFailedView,
     OutgoingConversationView: OutgoingConversationView
   };
 
 })(document.mozL10n || navigator.mozL10n);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -0,0 +1,78 @@
+/* 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.shared = loop.shared || {};
+loop.shared.actions = (function() {
+  "use strict";
+
+  /**
+   * Actions are events that are triggered by the user, e.g. clicking a button,
+   * or by an async event, e.g. status received.
+   *
+   * They should be dispatched to stores via the dispatcher.
+   */
+
+  function Action(name, schema, values) {
+    var validatedData = new loop.validate.Validator(schema || {})
+                                         .validate(values || {});
+    for (var prop in validatedData)
+      this[prop] = validatedData[prop];
+
+    this.name = name;
+  }
+
+  Action.define = function(name, schema) {
+    return Action.bind(null, name, schema);
+  };
+
+  return {
+    /**
+     * Used to trigger gathering of initial call data.
+     */
+    GatherCallData: Action.define("gatherCallData", {
+      // XXX This may change when bug 1072323 is implemented.
+      // Optional: Specify the calleeId for an outgoing call
+      calleeId: [String, null],
+      // Specify the callId for an incoming call.
+      callId: [String, null]
+    }),
+
+    /**
+     * Used to cancel call setup.
+     */
+    CancelCall: Action.define("cancelCall", {
+    }),
+
+    /**
+     * Used to initiate connecting of a call with the relevant
+     * sessionData.
+     */
+    ConnectCall: Action.define("connectCall", {
+      // This object contains the necessary details for the
+      // connection of the websocket, and the SDK
+      sessionData: Object
+    }),
+
+    /**
+     * Used for notifying of connection progress state changes.
+     * The connection refers to the overall connection flow as indicated
+     * on the websocket.
+     */
+    ConnectionProgress: Action.define("connectionProgress", {
+      // The new connection state
+      state: String
+    }),
+
+    /**
+     * Used for notifying of connection failures.
+     */
+    ConnectionFailure: Action.define("connectionFailure", {
+      // A string relating to the reason the connection failed.
+      reason: String
+    })
+  };
+})();
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -1,19 +1,244 @@
 /* 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.ConversationStore = (function() {
+loop.store = (function() {
+
+  var sharedActions = loop.shared.actions;
+  var sharedUtils = loop.shared.utils;
+
+  var CALL_STATES = {
+    // The initial state of the view.
+    INIT: "init",
+    // The store is gathering the call data from the server.
+    GATHER: "gather",
+    // The websocket has connected to the server and is waiting
+    // for the other peer to connect to the websocket.
+    CONNECTING: "connecting",
+    // The websocket has received information that we're now alerting
+    // the peer.
+    ALERTING: "alerting",
+    // The call was terminated due to an issue during connection.
+    TERMINATED: "terminated"
+  };
+
 
   var ConversationStore = Backbone.Model.extend({
     defaults: {
-      outgoing: false,
+      // The current state of the call
+      callState: CALL_STATES.INIT,
+      // The reason if a call was terminated
+      callStateReason: undefined,
+      // The error information, if there was a failure
+      error: undefined,
+      // True if the call is outgoing, false if not, undefined if unknown
+      outgoing: undefined,
+      // The id of the person being called for outgoing calls
       calleeId: undefined,
-      callState: "gather"
+      // The call type for the call.
+      // XXX Don't hard-code, this comes from the data in bug 1072323
+      callType: sharedUtils.CALL_TYPES.AUDIO_VIDEO,
+
+      // Call Connection information
+      // The call id from the loop-server
+      callId: undefined,
+      // The connection progress url to connect the websocket
+      progressURL: undefined,
+      // The websocket token that allows connection to the progress url
+      websocketToken: undefined,
+      // SDK API key
+      apiKey: undefined,
+      // SDK session ID
+      sessionId: undefined,
+      // SDK session token
+      sessionToken: undefined
+    },
+
+    /**
+     * Constructor
+     *
+     * Options:
+     * - {loop.Dispatcher} dispatcher The dispatcher for dispatching actions and
+     *                                registering to consume actions.
+     * - {Object} client              A client object for communicating with the server.
+     *
+     * @param  {Object} attributes Attributes object.
+     * @param  {Object} options    Options object.
+     */
+    initialize: function(attributes, options) {
+      options = options || {};
+
+      if (!options.dispatcher) {
+        throw new Error("Missing option dispatcher");
+      }
+      if (!options.client) {
+        throw new Error("Missing option client");
+      }
+
+      this.client = options.client;
+      this.dispatcher = options.dispatcher;
+
+      this.dispatcher.register(this, [
+        "connectionFailure",
+        "connectionProgress",
+        "gatherCallData",
+        "connectCall"
+      ]);
+    },
+
+    /**
+     * Handles the connection failure action, setting the state to
+     * terminated.
+     *
+     * @param {sharedActions.ConnectionFailure} actionData The action data.
+     */
+    connectionFailure: function(actionData) {
+      this.set({
+        callState: CALL_STATES.TERMINATED,
+        callStateReason: actionData.reason
+      });
+    },
+
+    /**
+     * Handles the connection progress action, setting the next state
+     * appropriately.
+     *
+     * @param {sharedActions.ConnectionProgress} actionData The action data.
+     */
+    connectionProgress: function(actionData) {
+      // XXX Turn this into a state machine?
+      if (actionData.state === "alerting" &&
+          (this.get("callState") === CALL_STATES.CONNECTING ||
+           this.get("callState") === CALL_STATES.GATHER)) {
+        this.set({
+          callState: CALL_STATES.ALERTING
+        });
+      }
+      if (actionData.state === "connecting" &&
+          this.get("callState") === CALL_STATES.GATHER) {
+        this.set({
+          callState: CALL_STATES.CONNECTING
+        });
+      }
     },
+
+    /**
+     * Handles the gather call data action, setting the state
+     * and starting to get the appropriate data for the type of call.
+     *
+     * @param {sharedActions.GatherCallData} actionData The action data.
+     */
+    gatherCallData: function(actionData) {
+      this.set({
+        calleeId: actionData.calleeId,
+        outgoing: !!actionData.calleeId,
+        callId: actionData.callId,
+        callState: CALL_STATES.GATHER
+      });
+
+      if (this.get("outgoing")) {
+        this._setupOutgoingCall();
+      } // XXX Else, other types aren't supported yet.
+    },
+
+    /**
+     * Handles the connect call action, this saves the appropriate
+     * data and starts the connection for the websocket to notify the
+     * server of progress.
+     *
+     * @param {sharedActions.ConnectCall} actionData The action data.
+     */
+    connectCall: function(actionData) {
+      this.set(actionData.sessionData);
+      this._connectWebSocket();
+    },
+
+    /**
+     * Obtains the outgoing call data from the server and handles the
+     * result.
+     */
+    _setupOutgoingCall: function() {
+      // XXX For now, we only have one calleeId, so just wrap that in an array.
+      this.client.setupOutgoingCall([this.get("calleeId")],
+        this.get("callType"),
+        function(err, result) {
+          if (err) {
+            console.error("Failed to get outgoing call data", err);
+            this.dispatcher.dispatch(
+              new sharedActions.ConnectionFailure({reason: "setup"}));
+            return;
+          }
+
+          // Success, dispatch a new action.
+          this.dispatcher.dispatch(
+            new sharedActions.ConnectCall({sessionData: result}));
+        }.bind(this)
+      );
+    },
+
+    /**
+     * Sets up and connects the websocket to the server. The websocket
+     * deals with sending and obtaining status via the server about the
+     * setup of the call.
+     */
+    _connectWebSocket: function() {
+      this._websocket = new loop.CallConnectionWebSocket({
+        url: this.get("progressURL"),
+        callId: this.get("callId"),
+        websocketToken: this.get("websocketToken")
+      });
+
+      this._websocket.promiseConnect().then(
+        function() {
+          this.dispatcher.dispatch(new sharedActions.ConnectionProgress({
+            // This is the websocket call state, i.e. waiting for the
+            // other end to connect to the server.
+            state: "connecting"
+          }));
+        }.bind(this),
+        function(error) {
+          console.error("Websocket failed to connect", error);
+          this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+            reason: "websocket-setup"
+          }));
+        }.bind(this)
+      );
+
+      this._websocket.on("progress", this._handleWebSocketProgress, this);
+    },
+
+    /**
+     * Used to handle any progressed received from the websocket. This will
+     * dispatch new actions so that the data can be handled appropriately.
+     */
+    _handleWebSocketProgress: function(progressData) {
+      var action;
+
+      switch(progressData.state) {
+        case "terminated":
+          action = new sharedActions.ConnectionFailure({
+            reason: progressData.reason
+          });
+          break;
+        case "alerting":
+          action = new sharedActions.ConnectionProgress({
+            state: progressData.state
+          });
+          break;
+        default:
+          console.warn("Received unexpected state in _handleWebSocketProgress", progressData.state);
+          return;
+      }
+
+      this.dispatcher.dispatch(action);
+    }
   });
 
-  return ConversationStore;
+  return {
+    CALL_STATES: CALL_STATES,
+    ConversationStore: ConversationStore
+  };
 })();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/dispatcher.js
@@ -0,0 +1,84 @@
+/* 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 */
+
+/**
+ * The dispatcher for actions. This dispatches actions to stores registered
+ * for those actions.
+ *
+ * If stores need to perform async operations for actions, they should return
+ * straight away, and set up a new action for the changes if necessary.
+ *
+ * It is an error if a returned promise rejects - they should always pass.
+ */
+var loop = loop || {};
+loop.Dispatcher = (function() {
+
+  function Dispatcher() {
+    this._eventData = {};
+    this._actionQueue = [];
+    this._debug = loop.shared.utils.getBoolPreference("debug.dispatcher");
+  }
+
+  Dispatcher.prototype = {
+    /**
+     * Register a store to receive notifications of specific actions.
+     *
+     * @param {Object} store The store object to register
+     * @param {Array} eventTypes An array of action names
+     */
+    register: function(store, eventTypes) {
+      eventTypes.forEach(function(type) {
+        if (this._eventData.hasOwnProperty(type)) {
+          this._eventData[type].push(store);
+        } else {
+          this._eventData[type] = [store];
+        }
+      }.bind(this));
+    },
+
+    /**
+     * Dispatches an action to all registered stores.
+     */
+    dispatch: function(action) {
+      // Always put it on the queue, to make it simpler.
+      this._actionQueue.push(action);
+      this._dispatchNextAction();
+    },
+
+    /**
+     * Dispatches the next action in the queue if one is not already active.
+     */
+    _dispatchNextAction: function() {
+      if (!this._actionQueue.length || this._active) {
+        return;
+      }
+
+      var action = this._actionQueue.shift();
+      var type = action.name;
+
+      var registeredStores = this._eventData[type];
+      if (!registeredStores) {
+        console.warn("No stores registered for event type ", type);
+        return;
+      }
+
+      this._active = true;
+
+      if (this._debug) {
+        console.log("[Dispatcher] Dispatching action", action);
+      }
+
+      registeredStores.forEach(function(store) {
+        store[type](action);
+      });
+
+      this._active = false;
+      this._dispatchNextAction();
+    }
+  };
+
+  return Dispatcher;
+})();
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -5,16 +5,24 @@
 /* global loop:true */
 
 var loop = loop || {};
 loop.shared = loop.shared || {};
 loop.shared.utils = (function() {
   "use strict";
 
   /**
+   * Call types used for determining if a call is audio/video or audio-only.
+   */
+  var CALL_TYPES = {
+    AUDIO_VIDEO: "audio-video",
+    AUDIO_ONLY: "audio"
+  };
+
+  /**
    * Used for adding different styles to the panel
    * @returns {String} Corresponds to the client platform
    * */
   function getTargetPlatform() {
     var platform="unknown_platform";
 
     if (navigator.platform.indexOf("Win") !== -1) {
       platform = "windows";
@@ -72,13 +80,14 @@ loop.shared.utils = (function() {
     },
 
     locationHash: function() {
       return window.location.hash;
     }
   };
 
   return {
+    CALL_TYPES: CALL_TYPES,
     Helper: Helper,
     getTargetPlatform: getTargetPlatform,
     getBoolPreference: getBoolPreference
   };
 })();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/validate.js
@@ -0,0 +1,127 @@
+/* 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/. */
+
+/* jshint unused:false */
+
+var loop = loop || {};
+loop.validate = (function() {
+  "use strict";
+
+  /**
+   * Computes the difference between two arrays.
+   *
+   * @param  {Array} arr1 First array
+   * @param  {Array} arr2 Second array
+   * @return {Array}      Array difference
+   */
+  function difference(arr1, arr2) {
+    return arr1.filter(function(item) {
+      return arr2.indexOf(item) === -1;
+    });
+  }
+
+  /**
+   * Retrieves the type name of an object or constructor. Fallback to "unknown"
+   * when it fails.
+   *
+   * @param  {Object} obj
+   * @return {String}
+   */
+  function typeName(obj) {
+    if (obj === null)
+      return "null";
+    if (typeof obj === "function")
+      return obj.name || obj.toString().match(/^function\s?([^\s(]*)/)[1];
+    if (typeof obj.constructor === "function")
+      return typeName(obj.constructor);
+    return "unknown";
+  }
+
+  /**
+   * Simple typed values validator.
+   *
+   * @constructor
+   * @param  {Object} schema Validation schema
+   */
+  function Validator(schema) {
+    this.schema = schema || {};
+  }
+
+  Validator.prototype = {
+    /**
+     * Validates all passed values against declared dependencies.
+     *
+     * @param  {Object} values  The values object
+     * @return {Object}         The validated values object
+     * @throws {TypeError}      If validation fails
+     */
+    validate: function(values) {
+      this._checkRequiredProperties(values);
+      this._checkRequiredTypes(values);
+      return values;
+    },
+
+    /**
+     * Checks if any of Object values matches any of current dependency type
+     * requirements.
+     *
+     * @param  {Object} values The values object
+     * @throws {TypeError}
+     */
+    _checkRequiredTypes: function(values) {
+      Object.keys(this.schema).forEach(function(name) {
+        var types = this.schema[name];
+        types = Array.isArray(types) ? types : [types];
+        if (!this._dependencyMatchTypes(values[name], types)) {
+          throw new TypeError("invalid dependency: " + name +
+                              "; expected " + types.map(typeName).join(", ") +
+                              ", got " + typeName(values[name]));
+        }
+      }, this);
+    },
+
+    /**
+     * Checks if a values object owns the required keys defined in dependencies.
+     * Values attached to these properties shouldn't be null nor undefined.
+     *
+     * @param  {Object} values The values object
+     * @throws {TypeError} If any dependency is missing.
+     */
+    _checkRequiredProperties: function(values) {
+      var definedProperties = Object.keys(values).filter(function(name) {
+        return typeof values[name] !== "undefined";
+      });
+      var diff = difference(Object.keys(this.schema), definedProperties);
+      if (diff.length > 0)
+        throw new TypeError("missing required " + diff.join(", "));
+    },
+
+    /**
+     * Checks if a given value matches any of the provided type requirements.
+     *
+     * @param  {Object} value  The value to check
+     * @param  {Array}  types  The list of types to check the value against
+     * @return {Boolean}
+     * @throws {TypeError} If the value doesn't match any types.
+     */
+    _dependencyMatchTypes: function(value, types) {
+      return types.some(function(Type) {
+        /*jshint eqeqeq:false*/
+        try {
+          return typeof Type === "undefined"         || // skip checking
+                 Type === null && value === null     || // null type
+                 value.constructor == Type           || // native type
+                 Type.prototype.isPrototypeOf(value) || // custom type
+                 typeName(value) === typeName(Type);    // type string eq.
+        } catch (e) {
+          return false;
+        }
+      });
+    }
+  };
+
+  return {
+    Validator: Validator
+  };
+})();
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -48,23 +48,26 @@ browser.jar:
   content/browser/loop/shared/img/svg/glyph-signin-16x16.svg    (content/shared/img/svg/glyph-signin-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-signout-16x16.svg   (content/shared/img/svg/glyph-signout-16x16.svg)
   content/browser/loop/shared/img/audio-call-avatar.svg         (content/shared/img/audio-call-avatar.svg)
   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/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/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)
   content/browser/loop/shared/js/websocket.js         (content/shared/js/websocket.js)
-  content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js)
 
   # Shared libs
 #ifdef DEBUG
   content/browser/loop/shared/libs/react-0.11.1.js    (content/shared/libs/react-0.11.1.js)
 #else
   content/browser/loop/shared/libs/react-0.11.1.js    (content/shared/libs/react-0.11.1-prod.js)
 #endif
   content/browser/loop/shared/libs/lodash-2.4.1.js    (content/shared/libs/lodash-2.4.1.js)
--- a/browser/components/loop/test/desktop-local/client_test.js
+++ b/browser/components/loop/test/desktop-local/client_test.js
@@ -248,10 +248,75 @@ describe("loop.Client", function() {
             sinon.assert.calledWith(mozLoop.telemetryAdd,
                                     "LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS",
                                     false);
 
             done();
           });
         });
     });
+
+    describe("#setupOutgoingCall", function() {
+      var calleeIds, callType;
+
+      beforeEach(function() {
+        calleeIds = [
+          "fakeemail", "fake phone"
+        ];
+        callType = "audio";
+      });
+
+      it("should make a POST call to /calls", function() {
+        client.setupOutgoingCall(calleeIds, callType);
+
+        sinon.assert.calledOnce(hawkRequestStub);
+        sinon.assert.calledWith(hawkRequestStub,
+          mozLoop.LOOP_SESSION_TYPE.FXA,
+          "/calls",
+          "POST",
+          { calleeId: calleeIds, callType: callType }
+        );
+      });
+
+      it("should call the callback if the request is successful", function() {
+        var requestData = {
+          apiKey: "fake",
+          callId: "fakeCall",
+          progressURL: "fakeurl",
+          sessionId: "12345678",
+          sessionToken: "15263748",
+          websocketToken: "13572468"
+        };
+
+        hawkRequestStub.callsArgWith(4, null, JSON.stringify(requestData));
+
+        client.setupOutgoingCall(calleeIds, callType, callback);
+
+        sinon.assert.calledOnce(callback);
+        sinon.assert.calledWithExactly(callback, null, requestData);
+      });
+
+      it("should send an error when the request fails", function() {
+        hawkRequestStub.callsArgWith(4, fakeErrorRes);
+
+        client.setupOutgoingCall(calleeIds, callType, callback);
+
+        sinon.assert.calledOnce(callback);
+        sinon.assert.calledWithExactly(callback, sinon.match(function(err) {
+          return /400.*invalid token/.test(err.message);
+        }));
+      });
+
+      it("should send an error if the data is not valid", function() {
+        // Sets up the hawkRequest stub to trigger the callback with
+        // an error
+        hawkRequestStub.callsArgWith(4, null, "{}");
+
+        client.setupOutgoingCall(calleeIds, callType, callback);
+
+        sinon.assert.calledOnce(callback);
+        sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
+          return /Invalid data received/.test(err.message);
+        }));
+      });
+    });
   });
 });
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var expect = chai.expect;
+
+describe("loop.conversationViews", function () {
+  var sandbox, oldTitle, view;
+
+  var CALL_STATES = loop.store.CALL_STATES;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+
+    oldTitle = document.title;
+    sandbox.stub(document.mozL10n, "get", function(x) {
+      return x;
+    });
+  });
+
+  afterEach(function() {
+    document.title = oldTitle;
+    view = undefined;
+    sandbox.restore();
+  });
+
+  describe("ConversationDetailView", function() {
+    function mountTestComponent(props) {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.ConversationDetailView(props));
+    }
+
+    it("should set the document title to the calledId", function() {
+      mountTestComponent({calleeId: "mrsmith"});
+
+      expect(document.title).eql("mrsmith");
+    });
+
+    it("should set display the calledId", function() {
+      view = mountTestComponent({calleeId: "mrsmith"});
+
+      expect(TestUtils.findRenderedDOMComponentWithTag(
+        view, "h2").props.children).eql("mrsmith");
+    });
+  });
+
+  describe("PendingConversationView", function() {
+    function mountTestComponent(props) {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.PendingConversationView(props));
+    }
+
+    it("should set display connecting string when the state is not alerting",
+      function() {
+        view = mountTestComponent({
+          callState: CALL_STATES.CONNECTING,
+          calleeId: "mrsmith"
+        });
+
+        var label = TestUtils.findRenderedDOMComponentWithClass(
+          view, "btn-label").props.children;
+
+        expect(label).to.have.string("connecting");
+    });
+
+    it("should set display ringing string when the state is alerting",
+      function() {
+        view = mountTestComponent({
+          callState: CALL_STATES.ALERTING,
+          calleeId: "mrsmith"
+        });
+
+        var label = TestUtils.findRenderedDOMComponentWithClass(
+          view, "btn-label").props.children;
+
+        expect(label).to.have.string("ringing");
+    });
+  });
+
+  describe("OutgoingConversationView", function() {
+    var store;
+
+    function mountTestComponent() {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.OutgoingConversationView({
+          store: store
+        }));
+    }
+
+    beforeEach(function() {
+      store = new loop.store.ConversationStore({}, {
+        dispatcher: new loop.Dispatcher(),
+        client: {}
+      });
+    });
+
+    it("should render the CallFailedView when the call state is 'terminated'",
+      function() {
+        store.set({callState: CALL_STATES.TERMINATED});
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversationViews.CallFailedView);
+    });
+
+    it("should render the PendingConversationView when the call state is connecting",
+      function() {
+        store.set({callState: CALL_STATES.CONNECTING});
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversationViews.PendingConversationView);
+    });
+
+    it("should update the rendered views when the state is changed.",
+      function() {
+        store.set({callState: CALL_STATES.CONNECTING});
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversationViews.PendingConversationView);
+
+        store.set({callState: CALL_STATES.TERMINATED});
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversationViews.CallFailedView);
+    });
+  });
+});
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -36,17 +36,17 @@ describe("loop.conversation", function()
       doNotDisturb: true,
       getStrings: function() {
         return JSON.stringify({textContent: "fakeText"});
       },
       get locale() {
         return "en-US";
       },
       setLoopCharPref: sinon.stub(),
-      getLoopCharPref: sinon.stub(),
+      getLoopCharPref: sinon.stub().returns(null),
       getLoopBoolPref: sinon.stub(),
       getCallData: sinon.stub(),
       releaseCallData: sinon.stub(),
       startAlerting: sinon.stub(),
       stopAlerting: sinon.stub(),
       ensureRegistered: sinon.stub(),
       get appVersionInfo() {
         return {
@@ -70,16 +70,21 @@ describe("loop.conversation", function()
   describe("#init", function() {
     beforeEach(function() {
       sandbox.stub(React, "renderComponent");
       sandbox.stub(document.mozL10n, "initialize");
 
       sandbox.stub(loop.shared.models.ConversationModel.prototype,
         "initialize");
 
+      sandbox.stub(loop.Dispatcher.prototype, "dispatch");
+
+      sandbox.stub(loop.shared.utils.Helper.prototype,
+        "locationHash").returns("#incoming/42");
+
       window.OT = {
         overrideGuidStorage: sinon.stub()
       };
     });
 
     afterEach(function() {
       delete window.OT;
     });
@@ -97,20 +102,31 @@ describe("loop.conversation", function()
 
       sinon.assert.calledOnce(React.renderComponent);
       sinon.assert.calledWith(React.renderComponent,
         sinon.match(function(value) {
           return TestUtils.isDescriptorOfType(value,
             loop.conversation.ConversationControllerView);
       }));
     });
+
+    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({
+          calleeId: null,
+          callId: "42"
+        }));
+    });
   });
 
   describe("ConversationControllerView", function() {
-    var store, conversation, client, ccView, oldTitle;
+    var store, conversation, client, ccView, oldTitle, dispatcher;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversation.ConversationControllerView({
           client: client,
           conversation: conversation,
           notifications: notifications,
           sdk: {},
@@ -119,17 +135,21 @@ describe("loop.conversation", function()
     }
 
     beforeEach(function() {
       oldTitle = document.title;
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
         sdk: {}
       });
-      store = new loop.ConversationStore();
+      dispatcher = new loop.Dispatcher();
+      store = new loop.store.ConversationStore({}, {
+        client: client,
+        dispatcher: dispatcher
+      });
     });
 
     afterEach(function() {
       ccView = undefined;
       document.title = oldTitle;
     });
 
     it("should display the OutgoingConversationView for outgoing calls", function() {
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -34,26 +34,30 @@
   <!-- App scripts -->
   <script src="../../content/shared/js/utils.js"></script>
   <script src="../../content/shared/js/feedbackApiClient.js"></script>
   <script src="../../content/shared/js/conversationStore.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/actions.js"></script>
+  <script src="../../content/shared/js/validate.js"></script>
+  <script src="../../content/shared/js/dispatcher.js"></script>
   <script src="../../content/js/client.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="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>");
     });
   </script>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -0,0 +1,321 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var expect = chai.expect;
+
+describe("loop.ConversationStore", function () {
+  "use strict";
+
+  var CALL_STATES = loop.store.CALL_STATES;
+  var sharedActions = loop.shared.actions;
+  var sharedUtils = loop.shared.utils;
+  var sandbox, dispatcher, client, store, fakeSessionData;
+  var connectPromise, resolveConnectPromise, rejectConnectPromise;
+
+  function checkFailures(done, f) {
+    try {
+      f();
+      done();
+    } catch (err) {
+      done(err);
+    }
+  }
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+
+    dispatcher = new loop.Dispatcher();
+    client = {
+      setupOutgoingCall: sinon.stub()
+    };
+    store = new loop.store.ConversationStore({}, {
+      client: client,
+      dispatcher: dispatcher
+    });
+    fakeSessionData = {
+      apiKey: "fakeKey",
+      callId: "142536",
+      sessionId: "321456",
+      sessionToken: "341256",
+      websocketToken: "543216",
+      progressURL: "fakeURL"
+    };
+
+    var dummySocket = {
+      close: sinon.spy(),
+      send: sinon.spy()
+    };
+
+    connectPromise = new Promise(function(resolve, reject) {
+      resolveConnectPromise = resolve;
+      rejectConnectPromise = reject;
+    });
+
+    sandbox.stub(loop.CallConnectionWebSocket.prototype,
+      "promiseConnect").returns(connectPromise);
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("#initialize", function() {
+    it("should throw an error if the dispatcher is missing", function() {
+      expect(function() {
+        new loop.store.ConversationStore({}, {client: client});
+      }).to.Throw(/dispatcher/);
+    });
+
+    it("should throw an error if the client is missing", function() {
+      expect(function() {
+        new loop.store.ConversationStore({}, {dispatcher: dispatcher});
+      }).to.Throw(/client/);
+    });
+  });
+
+  describe("#connectionFailure", function() {
+    it("should set the state to 'terminated'", function() {
+      store.set({callState: CALL_STATES.ALERTING});
+
+      dispatcher.dispatch(
+        new sharedActions.ConnectionFailure({reason: "fake"}));
+
+      expect(store.get("callState")).eql(CALL_STATES.TERMINATED);
+      expect(store.get("callStateReason")).eql("fake");
+    });
+  });
+
+  describe("#connectionProgress", function() {
+    describe("progress: connecting", function() {
+      it("should change the state from 'gather' to 'connecting'", function() {
+        store.set({callState: CALL_STATES.GATHER});
+
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({state: "connecting"}));
+
+        expect(store.get("callState")).eql(CALL_STATES.CONNECTING);
+      });
+    });
+
+    describe("progress: alerting", function() {
+      it("should set the state from 'gather' to 'alerting'", function() {
+        store.set({callState: CALL_STATES.GATHER});
+
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({state: "alerting"}));
+
+        expect(store.get("callState")).eql(CALL_STATES.ALERTING);
+      });
+
+      it("should set the state from 'connecting' to 'alerting'", function() {
+        store.set({callState: CALL_STATES.CONNECTING});
+
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({state: "alerting"}));
+
+        expect(store.get("callState")).eql(CALL_STATES.ALERTING);
+      });
+    });
+  });
+
+  describe("#gatherCallData", function() {
+    beforeEach(function() {
+      store.set({callState: CALL_STATES.INIT});
+    });
+
+    it("should set the state to 'gather'", function() {
+      dispatcher.dispatch(
+        new sharedActions.GatherCallData({
+          calleeId: "",
+          callId: "76543218"
+        }));
+
+      expect(store.get("callState")).eql(CALL_STATES.GATHER);
+    });
+
+    it("should save the basic call information", function() {
+      dispatcher.dispatch(
+        new sharedActions.GatherCallData({
+          calleeId: "fake",
+          callId: "123456"
+        }));
+
+      expect(store.get("calleeId")).eql("fake");
+      expect(store.get("callId")).eql("123456");
+      expect(store.get("outgoing")).eql(true);
+    });
+
+    describe("outgoing calls", function() {
+      var outgoingCallData;
+
+      beforeEach(function() {
+        outgoingCallData = {
+          calleeId: "fake",
+          callId: "135246"
+        };
+      });
+
+      it("should request the outgoing call data", function() {
+        dispatcher.dispatch(
+          new sharedActions.GatherCallData(outgoingCallData));
+
+        sinon.assert.calledOnce(client.setupOutgoingCall);
+        sinon.assert.calledWith(client.setupOutgoingCall,
+          ["fake"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
+      });
+
+      describe("server response handling", function() {
+        beforeEach(function() {
+          sandbox.stub(dispatcher, "dispatch");
+        });
+
+        it("should dispatch a connect call action on success", function() {
+          var callData = {
+            apiKey: "fakeKey"
+          };
+
+          client.setupOutgoingCall.callsArgWith(2, null, callData);
+
+          store.gatherCallData(
+            new sharedActions.GatherCallData(outgoingCallData));
+
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          // Can't use instanceof here, as that matches any action
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("name", "connectCall"));
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("sessionData", callData));
+        });
+
+        it("should dispatch a connection failure action on failure", function() {
+          client.setupOutgoingCall.callsArgWith(2, {});
+
+          store.gatherCallData(
+            new sharedActions.GatherCallData(outgoingCallData));
+
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          // Can't use instanceof here, as that matches any action
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("name", "connectionFailure"));
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("reason", "setup"));
+        });
+      });
+    });
+  });
+
+  describe("#connectCall", function() {
+    it("should save the call session data", function() {
+      dispatcher.dispatch(
+        new sharedActions.ConnectCall({sessionData: fakeSessionData}));
+
+      expect(store.get("apiKey")).eql("fakeKey");
+      expect(store.get("callId")).eql("142536");
+      expect(store.get("sessionId")).eql("321456");
+      expect(store.get("sessionToken")).eql("341256");
+      expect(store.get("websocketToken")).eql("543216");
+      expect(store.get("progressURL")).eql("fakeURL");
+    });
+
+    it("should initialize the websocket", function() {
+      sandbox.stub(loop, "CallConnectionWebSocket").returns({
+        promiseConnect: function() { return connectPromise; },
+        on: sinon.spy()
+      });
+
+      dispatcher.dispatch(
+        new sharedActions.ConnectCall({sessionData: fakeSessionData}));
+
+      sinon.assert.calledOnce(loop.CallConnectionWebSocket);
+      sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, {
+        url: "fakeURL",
+        callId: "142536",
+        websocketToken: "543216"
+      });
+    });
+
+    it("should connect the websocket to the server", function() {
+      dispatcher.dispatch(
+        new sharedActions.ConnectCall({sessionData: fakeSessionData}));
+
+      sinon.assert.calledOnce(store._websocket.promiseConnect);
+    });
+
+    describe("WebSocket connection result", function() {
+      beforeEach(function() {
+        dispatcher.dispatch(
+          new sharedActions.ConnectCall({sessionData: fakeSessionData}));
+
+        sandbox.stub(dispatcher, "dispatch");
+      });
+
+      it("should dispatch a connection progress action on success", function(done) {
+        resolveConnectPromise();
+
+        connectPromise.then(function() {
+          checkFailures(done, function() {
+            sinon.assert.calledOnce(dispatcher.dispatch);
+            // Can't use instanceof here, as that matches any action
+            sinon.assert.calledWithMatch(dispatcher.dispatch,
+              sinon.match.hasOwn("name", "connectionProgress"));
+            sinon.assert.calledWithMatch(dispatcher.dispatch,
+              sinon.match.hasOwn("state", "connecting"));
+          });
+        }, function() {
+          done(new Error("Promise should have been resolve, not rejected"));
+        });
+      });
+
+      it("should dispatch a connection failure action on failure", function(done) {
+        rejectConnectPromise();
+
+        connectPromise.then(function() {
+          done(new Error("Promise should have been rejected, not resolved"));
+        }, function() {
+          checkFailures(done, function() {
+            sinon.assert.calledOnce(dispatcher.dispatch);
+            // Can't use instanceof here, as that matches any action
+            sinon.assert.calledWithMatch(dispatcher.dispatch,
+              sinon.match.hasOwn("name", "connectionFailure"));
+            sinon.assert.calledWithMatch(dispatcher.dispatch,
+              sinon.match.hasOwn("reason", "websocket-setup"));
+           });
+        });
+      });
+
+    });
+  });
+
+  describe("Events", function() {
+    describe("Websocket progress", function() {
+      beforeEach(function() {
+        dispatcher.dispatch(
+          new sharedActions.ConnectCall({sessionData: fakeSessionData}));
+
+        sandbox.stub(dispatcher, "dispatch");
+      });
+
+      it("should dispatch a connection failure action on 'terminate'", function() {
+        store._websocket.trigger("progress", {state: "terminated", reason: "reject"});
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        // Can't use instanceof here, as that matches any action
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionFailure"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("reason", "reject"));
+      });
+
+      it("should dispatch a connection progress action on 'alerting'", function() {
+        store._websocket.trigger("progress", {state: "alerting"});
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        // Can't use instanceof here, as that matches any action
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionProgress"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("state", "alerting"));
+      });
+    });
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/dispatcher_test.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var expect = chai.expect;
+
+describe("loop.Dispatcher", function () {
+  "use strict";
+
+  var sharedActions = loop.shared.actions;
+  var dispatcher, sandbox;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+    dispatcher = new loop.Dispatcher();
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("#register", function() {
+    it("should register a store against an action name", function() {
+      var object = { fake: true };
+
+      dispatcher.register(object, ["gatherCallData"]);
+
+      expect(dispatcher._eventData["gatherCallData"][0]).eql(object);
+    });
+
+    it("should register multiple store against an action name", function() {
+      var object1 = { fake: true };
+      var object2 = { fake2: true };
+
+      dispatcher.register(object1, ["gatherCallData"]);
+      dispatcher.register(object2, ["gatherCallData"]);
+
+      expect(dispatcher._eventData["gatherCallData"][0]).eql(object1);
+      expect(dispatcher._eventData["gatherCallData"][1]).eql(object2);
+    });
+  });
+
+  describe("#dispatch", function() {
+    var gatherStore1, gatherStore2, cancelStore1, connectStore1;
+    var gatherAction, cancelAction, connectAction, resolveCancelStore1;
+
+    beforeEach(function() {
+      gatherAction = new sharedActions.GatherCallData({
+        callId: "42",
+        calleeId: null
+      });
+
+      cancelAction = new sharedActions.CancelCall();
+      connectAction = new sharedActions.ConnectCall({
+        sessionData: {}
+      });
+
+      gatherStore1 = {
+        gatherCallData: sinon.stub()
+      };
+      gatherStore2 = {
+        gatherCallData: sinon.stub()
+      };
+      cancelStore1 = {
+        cancelCall: sinon.stub()
+      };
+      connectStore1 = {
+        connectCall: function() {}
+      };
+
+      dispatcher.register(gatherStore1, ["gatherCallData"]);
+      dispatcher.register(gatherStore2, ["gatherCallData"]);
+      dispatcher.register(cancelStore1, ["cancelCall"]);
+      dispatcher.register(connectStore1, ["connectCall"]);
+    });
+
+    it("should dispatch an action to the required object", function() {
+      dispatcher.dispatch(cancelAction);
+
+      sinon.assert.notCalled(gatherStore1.gatherCallData);
+
+      sinon.assert.calledOnce(cancelStore1.cancelCall);
+      sinon.assert.calledWithExactly(cancelStore1.cancelCall, cancelAction);
+
+      sinon.assert.notCalled(gatherStore2.gatherCallData);
+    });
+
+    it("should dispatch actions to multiple objects", function() {
+      dispatcher.dispatch(gatherAction);
+
+      sinon.assert.calledOnce(gatherStore1.gatherCallData);
+      sinon.assert.calledWithExactly(gatherStore1.gatherCallData, gatherAction);
+
+      sinon.assert.notCalled(cancelStore1.cancelCall);
+
+      sinon.assert.calledOnce(gatherStore2.gatherCallData);
+      sinon.assert.calledWithExactly(gatherStore2.gatherCallData, gatherAction);
+    });
+
+    it("should dispatch multiple actions", function() {
+      dispatcher.dispatch(cancelAction);
+      dispatcher.dispatch(gatherAction);
+
+      sinon.assert.calledOnce(cancelStore1.cancelCall);
+      sinon.assert.calledOnce(gatherStore1.gatherCallData);
+      sinon.assert.calledOnce(gatherStore2.gatherCallData);
+    });
+
+    describe("Queued actions", function() {
+      beforeEach(function() {
+        // Restore the stub, so that we can easily add a function to be
+        // returned. Unfortunately, sinon doesn't make this easy.
+        sandbox.stub(connectStore1, "connectCall", function() {
+          dispatcher.dispatch(gatherAction);
+
+          sinon.assert.notCalled(gatherStore1.gatherCallData);
+          sinon.assert.notCalled(gatherStore2.gatherCallData);
+        });
+      });
+
+      it("should not dispatch an action if the previous action hasn't finished", function() {
+        // Dispatch the first action. The action handler dispatches the second
+        // action - see the beforeEach above.
+        dispatcher.dispatch(connectAction);
+
+        sinon.assert.calledOnce(connectStore1.connectCall);
+      });
+
+      it("should dispatch an action when the previous action finishes", function() {
+        // Dispatch the first action. The action handler dispatches the second
+        // action - see the beforeEach above.
+        dispatcher.dispatch(connectAction);
+
+        sinon.assert.calledOnce(connectStore1.connectCall);
+        // These should be called, because the dispatcher synchronously queues actions.
+        sinon.assert.calledOnce(gatherStore1.gatherCallData);
+        sinon.assert.calledOnce(gatherStore2.gatherCallData);
+      });
+    });
+  });
+});
--- a/browser/components/loop/test/shared/index.html
+++ b/browser/components/loop/test/shared/index.html
@@ -34,23 +34,30 @@
 
   <!-- 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/validate.js"></script>
+  <script src="../../content/shared/js/actions.js"></script>
+  <script src="../../content/shared/js/dispatcher.js"></script>
+  <script src="../../content/shared/js/conversationStore.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="conversationStore_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
   </script>
 </body>
 </html>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/validate_test.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*global chai, validate */
+
+var expect = chai.expect;
+
+describe("Validator", function() {
+  "use strict";
+
+  // test helpers
+  function create(dependencies, values) {
+    var validator = new loop.validate.Validator(dependencies);
+    return validator.validate.bind(validator, values);
+  }
+
+  // test types
+  function X(){}
+  function Y(){}
+
+  describe("#validate", function() {
+    it("should check for a single required dependency when no option passed",
+      function() {
+        expect(create({x: Number}, {}))
+          .to.Throw(TypeError, /missing required x$/);
+      });
+
+    it("should check for a missing required dependency, undefined passed",
+      function() {
+        expect(create({x: Number}, {x: undefined}))
+          .to.Throw(TypeError, /missing required x$/);
+      });
+
+    it("should check for multiple missing required dependencies", function() {
+      expect(create({x: Number, y: String}, {}))
+        .to.Throw(TypeError, /missing required x, y$/);
+    });
+
+    it("should check for required dependency types", function() {
+      expect(create({x: Number}, {x: "woops"})).to.Throw(
+        TypeError, /invalid dependency: x; expected Number, got String$/);
+    });
+
+    it("should check for a dependency to match at least one of passed types",
+      function() {
+        expect(create({x: [X, Y]}, {x: 42})).to.Throw(
+          TypeError, /invalid dependency: x; expected X, Y, got Number$/);
+        expect(create({x: [X, Y]}, {x: new Y()})).to.not.Throw();
+      });
+
+    it("should skip type check if required dependency type is undefined",
+      function() {
+        expect(create({x: undefined}, {x: /whatever/})).not.to.Throw();
+      });
+
+    it("should check for a String dependency", function() {
+      expect(create({foo: String}, {foo: 42})).to.Throw(
+        TypeError, /invalid dependency: foo/);
+    });
+
+    it("should check for a Number dependency", function() {
+      expect(create({foo: Number}, {foo: "x"})).to.Throw(
+        TypeError, /invalid dependency: foo/);
+    });
+
+    it("should check for a custom constructor dependency", function() {
+      expect(create({foo: X}, {foo: null})).to.Throw(
+        TypeError, /invalid dependency: foo; expected X, got null$/);
+    });
+
+    it("should check for a native constructor dependency", function() {
+      expect(create({foo: mozRTCSessionDescription}, {foo: "x"}))
+        .to.Throw(TypeError,
+                  /invalid dependency: foo; expected mozRTCSessionDescription/);
+    });
+
+    it("should check for a null dependency", function() {
+      expect(create({foo: null}, {foo: "x"})).to.Throw(
+        TypeError, /invalid dependency: foo; expected null, got String$/);
+    });
+  });
+});