Bug 1022594 - Part 1: Change Loop's incoming call handling to get the call details before displaying the incoming call UI. r=nperriault, a=lmandel
authorMark Banner <standard8@mozilla.com>
Tue, 05 Aug 2014 15:54:55 +0100
changeset 216678 e0ad01b2e26e
parent 216677 db5539e42eb5
child 216679 062929c9ff5d
push id3872
push userryanvm@gmail.com
push date2014-09-08 19:43 +0000
treeherdermozilla-beta@d820ef3b256d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnperriault, lmandel
bugs1022594
milestone33.0
Bug 1022594 - Part 1: Change Loop's incoming call handling to get the call details before displaying the incoming call UI. r=nperriault, a=lmandel
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/panel.js
browser/components/loop/content/shared/js/models.js
browser/components/loop/content/shared/js/router.js
browser/components/loop/content/shared/js/views.js
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/shared/models_test.js
browser/components/loop/test/shared/router_test.js
browser/components/loop/test/standalone/webapp_test.js
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -19,17 +19,17 @@ loop.conversation = (function(OT, mozL10
    * App router.
    * @type {loop.desktopRouter.DesktopConversationRouter}
    */
   var router;
 
   var IncomingCallView = React.createClass({displayName: 'IncomingCallView',
 
     propTypes: {
-      model: React.PropTypes.func.isRequired
+      model: React.PropTypes.object.isRequired
     },
 
     /**
      * Used for adding different styles to the panel
      * @returns {String} Corresponds to the client platform
      * */
     _getTargetPlatform: function() {
       var platform="unknown_platform";
@@ -56,23 +56,23 @@ loop.conversation = (function(OT, mozL10
     },
 
     render: function() {
       /* jshint ignore:start */
       var btnClassAccept = "btn btn-error btn-decline";
       var btnClassDecline = "btn btn-success btn-accept";
       var conversationPanelClass = "incoming-call " + this._getTargetPlatform();
       return (
-        React.DOM.div( {className:conversationPanelClass}, 
-          React.DOM.h2(null, __("incoming_call")),
-          React.DOM.div( {className:"button-group"}, 
-            React.DOM.button( {className:btnClassAccept, onClick:this._handleDecline}, 
+        React.DOM.div({className: conversationPanelClass}, 
+          React.DOM.h2(null, __("incoming_call")), 
+          React.DOM.div({className: "button-group"}, 
+            React.DOM.button({className: btnClassAccept, onClick: this._handleDecline}, 
               __("incoming_call_decline_button")
-            ),
-            React.DOM.button( {className:btnClassDecline, onClick:this._handleAccept}, 
+            ), 
+            React.DOM.button({className: btnClassDecline, onClick: this._handleAccept}, 
               __("incoming_call_answer_button")
             )
           )
         )
       );
       /* jshint ignore:end */
     }
   });
@@ -137,36 +137,49 @@ loop.conversation = (function(OT, mozL10
      * Incoming call route.
      *
      * @param {String} loopVersion The version from the push notification, set
      *                             by the router from the URL.
      */
     incoming: function(loopVersion) {
       window.navigator.mozLoop.startAlerting();
       this._conversation.set({loopVersion: loopVersion});
-      this._conversation.once("accept", function() {
+      this._conversation.once("accept", () => {
         this.navigate("call/accept", {trigger: true});
-      }.bind(this));
-      this._conversation.once("decline", function() {
+      });
+      this._conversation.once("decline", () => {
         this.navigate("call/decline", {trigger: true});
-      }.bind(this));
-      this.loadReactComponent(loop.conversation.IncomingCallView({
-        model: this._conversation
-      }));
+      });
+      this._conversation.once("call:incoming", this.startCall, this);
+      this._client.requestCallsInfo(loopVersion, (err, sessionData) => {
+        if (err) {
+          console.error("Failed to get the sessionData", err);
+          // XXX Not the ideal response, but bug 1047410 will be replacing
+          //this by better "call failed" UI.
+          this._notifier.errorL10n("cannot_start_call_session_not_ready");
+          return;
+        }
+        // XXX For incoming calls we might have more than one call queued.
+        // For now, we'll just assume the first call is the right information.
+        // We'll probably really want to be getting this data from the
+        // background worker on the desktop client.
+        // Bug 1032700 should fix this.
+        this._conversation.setSessionData(sessionData[0]);
+        this.loadReactComponent(loop.conversation.IncomingCallView({
+          model: this._conversation
+        }));
+      });
     },
 
     /**
      * Accepts an incoming call.
      */
     accept: function() {
       window.navigator.mozLoop.stopAlerting();
-      this._conversation.initiate({
-        client: new loop.Client(),
-        outgoing: false
-      });
+      this._conversation.incoming();
     },
 
     /**
      * Declines an incoming call.
      */
     decline: function() {
       window.navigator.mozLoop.stopAlerting();
       // XXX For now, we just close the window
@@ -205,18 +218,22 @@ loop.conversation = (function(OT, mozL10
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
     // else to ensure the L10n environment is setup correctly.
     mozL10n.initialize(window.navigator.mozLoop);
 
     document.title = mozL10n.get("incoming_call_title");
 
+    var client = new loop.Client();
     router = new ConversationRouter({
-      conversation: new loop.shared.models.ConversationModel({}, {sdk: OT}),
+      client: client,
+      conversation: new loop.shared.models.ConversationModel(
+        {},         // Model attributes
+        {sdk: OT}), // Model dependencies
       notifier: new sharedViews.NotificationListView({el: "#messages"})
     });
     Backbone.history.start();
   }
 
   return {
     ConversationRouter: ConversationRouter,
     EndedCallView: EndedCallView,
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -19,17 +19,17 @@ loop.conversation = (function(OT, mozL10
    * App router.
    * @type {loop.desktopRouter.DesktopConversationRouter}
    */
   var router;
 
   var IncomingCallView = React.createClass({
 
     propTypes: {
-      model: React.PropTypes.func.isRequired
+      model: React.PropTypes.object.isRequired
     },
 
     /**
      * Used for adding different styles to the panel
      * @returns {String} Corresponds to the client platform
      * */
     _getTargetPlatform: function() {
       var platform="unknown_platform";
@@ -137,36 +137,49 @@ loop.conversation = (function(OT, mozL10
      * Incoming call route.
      *
      * @param {String} loopVersion The version from the push notification, set
      *                             by the router from the URL.
      */
     incoming: function(loopVersion) {
       window.navigator.mozLoop.startAlerting();
       this._conversation.set({loopVersion: loopVersion});
-      this._conversation.once("accept", function() {
+      this._conversation.once("accept", () => {
         this.navigate("call/accept", {trigger: true});
-      }.bind(this));
-      this._conversation.once("decline", function() {
+      });
+      this._conversation.once("decline", () => {
         this.navigate("call/decline", {trigger: true});
-      }.bind(this));
-      this.loadReactComponent(loop.conversation.IncomingCallView({
-        model: this._conversation
-      }));
+      });
+      this._conversation.once("call:incoming", this.startCall, this);
+      this._client.requestCallsInfo(loopVersion, (err, sessionData) => {
+        if (err) {
+          console.error("Failed to get the sessionData", err);
+          // XXX Not the ideal response, but bug 1047410 will be replacing
+          //this by better "call failed" UI.
+          this._notifier.errorL10n("cannot_start_call_session_not_ready");
+          return;
+        }
+        // XXX For incoming calls we might have more than one call queued.
+        // For now, we'll just assume the first call is the right information.
+        // We'll probably really want to be getting this data from the
+        // background worker on the desktop client.
+        // Bug 1032700 should fix this.
+        this._conversation.setSessionData(sessionData[0]);
+        this.loadReactComponent(loop.conversation.IncomingCallView({
+          model: this._conversation
+        }));
+      });
     },
 
     /**
      * Accepts an incoming call.
      */
     accept: function() {
       window.navigator.mozLoop.stopAlerting();
-      this._conversation.initiate({
-        client: new loop.Client(),
-        outgoing: false
-      });
+      this._conversation.incoming();
     },
 
     /**
      * Declines an incoming call.
      */
     decline: function() {
       window.navigator.mozLoop.stopAlerting();
       // XXX For now, we just close the window
@@ -205,18 +218,22 @@ loop.conversation = (function(OT, mozL10
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
     // else to ensure the L10n environment is setup correctly.
     mozL10n.initialize(window.navigator.mozLoop);
 
     document.title = mozL10n.get("incoming_call_title");
 
+    var client = new loop.Client();
     router = new ConversationRouter({
-      conversation: new loop.shared.models.ConversationModel({}, {sdk: OT}),
+      client: client,
+      conversation: new loop.shared.models.ConversationModel(
+        {},         // Model attributes
+        {sdk: OT}), // Model dependencies
       notifier: new sharedViews.NotificationListView({el: "#messages"})
     });
     Backbone.history.start();
   }
 
   return {
     ConversationRouter: ConversationRouter,
     EndedCallView: EndedCallView,
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -72,32 +72,32 @@ loop.panel = (function(_, mozL10n) {
         'dnd-menu': true,
         'hide': !this.state.showMenu
       });
       var availabilityText = this.state.doNotDisturb ?
                               __("display_name_dnd_status") :
                               __("display_name_available_status");
 
       return (
-        React.DOM.div( {className:"footer component-spacer"}, 
-          React.DOM.div( {className:"do-not-disturb"}, 
-            React.DOM.p( {className:"dnd-status", onClick:this.showDropdownMenu}, 
-              React.DOM.span(null, availabilityText),
-              React.DOM.i( {className:availabilityStatus})
-            ),
-            React.DOM.ul( {className:availabilityDropdown,
-                onMouseLeave:this.hideDropdownMenu}, 
-              React.DOM.li( {onClick:this.changeAvailability("available"),
-                  className:"dnd-menu-item dnd-make-available"}, 
-                React.DOM.i( {className:"status status-available"}),
+        React.DOM.div({className: "footer component-spacer"}, 
+          React.DOM.div({className: "do-not-disturb"}, 
+            React.DOM.p({className: "dnd-status", onClick: this.showDropdownMenu}, 
+              React.DOM.span(null, availabilityText), 
+              React.DOM.i({className: availabilityStatus})
+            ), 
+            React.DOM.ul({className: availabilityDropdown, 
+                onMouseLeave: this.hideDropdownMenu}, 
+              React.DOM.li({onClick: this.changeAvailability("available"), 
+                  className: "dnd-menu-item dnd-make-available"}, 
+                React.DOM.i({className: "status status-available"}), 
                 React.DOM.span(null, __("display_name_available_status"))
-              ),
-              React.DOM.li( {onClick:this.changeAvailability("do-not-disturb"),
-                  className:"dnd-menu-item dnd-make-unavailable"}, 
-                React.DOM.i( {className:"status status-dnd"}),
+              ), 
+              React.DOM.li({onClick: this.changeAvailability("do-not-disturb"), 
+                  className: "dnd-menu-item dnd-make-unavailable"}, 
+                React.DOM.i({className: "status status-dnd"}), 
                 React.DOM.span(null, __("display_name_dnd_status"))
               )
             )
           )
         )
       );
     }
   });
@@ -110,36 +110,36 @@ loop.panel = (function(_, mozL10n) {
     render: function() {
       var tosHTML = __("legal_text_and_links", {
         "terms_of_use_url": "https://accounts.firefox.com/legal/terms",
         "privacy_notice_url": "www.mozilla.org/privacy/"
       });
 
       if (this.state.seenToS == "unseen") {
         navigator.mozLoop.setLoopCharPref('seenToS', 'seen');
-        return React.DOM.p( {className:"terms-service",
-                  dangerouslySetInnerHTML:{__html: tosHTML}});
+        return React.DOM.p({className: "terms-service", 
+                  dangerouslySetInnerHTML: {__html: tosHTML}});
       } else {
-        return React.DOM.div(null );
+        return React.DOM.div(null);
       }
     }
   });
 
   var PanelLayout = React.createClass({displayName: 'PanelLayout',
     propTypes: {
       summary: React.PropTypes.string.isRequired
     },
 
     render: function() {
       return (
-        React.DOM.div( {className:"component-spacer share generate-url"}, 
-          React.DOM.div( {className:"description"}, 
-            React.DOM.p( {className:"description-content"}, this.props.summary)
-          ),
-          React.DOM.div( {className:"action"}, 
+        React.DOM.div({className: "component-spacer share generate-url"}, 
+          React.DOM.div({className: "description"}, 
+            React.DOM.p({className: "description-content"}, this.props.summary)
+          ), 
+          React.DOM.div({className: "action"}, 
             this.props.children
           )
         )
       );
     }
   });
 
   var CallUrlResult = React.createClass({displayName: 'CallUrlResult',
@@ -187,20 +187,20 @@ loop.panel = (function(_, mozL10n) {
 
     render: function() {
       // XXX setting elem value from a state (in the callUrl input)
       // makes it immutable ie read only but that is fine in our case.
       // readOnly attr will suppress a warning regarding this issue
       // from the react lib.
       var cx = React.addons.classSet;
       return (
-        PanelLayout( {summary:__("share_link_header_text")}, 
-          React.DOM.div( {className:"invite"}, 
-            React.DOM.input( {type:"url", value:this.state.callUrl, readOnly:"true",
-                   className:cx({'pending': this.state.pending})} )
+        PanelLayout({summary: __("share_link_header_text")}, 
+          React.DOM.div({className: "invite"}, 
+            React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true", 
+                   className: cx({'pending': this.state.pending})})
           )
         )
       );
     }
   });
 
   /**
    * Panel view.
@@ -209,20 +209,20 @@ loop.panel = (function(_, mozL10n) {
     propTypes: {
       notifier: React.PropTypes.object.isRequired,
       client: React.PropTypes.object.isRequired
     },
 
     render: function() {
       return (
         React.DOM.div(null, 
-          CallUrlResult( {client:this.props.client,
-                       notifier:this.props.notifier} ),
-          ToSView(null ),
-          AvailabilityDropdown(null )
+          CallUrlResult({client: this.props.client, 
+                       notifier: this.props.notifier}), 
+          ToSView(null), 
+          AvailabilityDropdown(null)
         )
       );
     }
   });
 
   var PanelRouter = loop.desktopRouter.DesktopRouter.extend({
     /**
      * DOM document object.
@@ -279,18 +279,18 @@ loop.panel = (function(_, mozL10n) {
     /**
      * Resets this router to its initial state.
      */
     reset: function() {
       this._notifier.clear();
       var client = new loop.Client({
         baseServerUrl: navigator.mozLoop.serverUrl
       });
-      this.loadReactComponent(PanelView( {client:client,
-                                         notifier:this._notifier} ));
+      this.loadReactComponent(PanelView({client: client, 
+                                         notifier: this._notifier}));
     }
   });
 
   /**
    * Panel initialisation.
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -74,107 +74,76 @@ loop.shared.models = (function() {
       this.sdk = options.sdk;
       this.pendingCallTimeout = options.pendingCallTimeout || 20000;
 
       // Ensure that any pending call timer is cleared on disconnect/error
       this.on("session:ended session:error", this._clearPendingCallTimer, this);
     },
 
     /**
-     * Initiates a conversation, requesting call session information to the Loop
-     * server and updates appropriately the current model attributes with the
-     * data.
-     *
-     * Available options:
+     * Starts an incoming conversation.
+     */
+    incoming: function() {
+      this.trigger("call:incoming");
+    },
+
+    /**
+     * Used to indicate that an outgoing call should start any necessary
+     * set-up.
+     */
+    setupOutgoingCall: function() {
+      this.trigger("call:outgoing:setup");
+    },
+
+    /**
+     * Starts an outgoing conversation.
      *
-     * - {Boolean} outgoing Set to true if this model represents the
-     *                            outgoing call.
-     * - {Boolean} callType Only valid for outgoing calls. The type of media in
-     *                      the call, e.g. "audio" or "audio-video"
-     * - {loop.shared.Client} client  A client object to request call information
-     *                                from. Expects requestCallInfo for outgoing
-     *                                calls, requestCallsInfo for incoming calls.
-     *
-     * Triggered events:
-     *
-     * - `session:ready` when the session information have been successfully
-     *   retrieved from the server;
-     * - `session:error` when the request failed.
-     *
-     * @param {Object} options Options object
+     * @param {Object} sessionData The session data received from the
+     *                             server for the outgoing call.
      */
-    initiate: function(options) {
-      options = options || {};
+    outgoing: function(sessionData) {
+      this._clearPendingCallTimer();
 
       // Outgoing call has never reached destination, closing - see bug 1020448
       function handleOutgoingCallTimeout() {
         /*jshint validthis:true */
         if (!this.get("ongoing")) {
           this.trigger("timeout").endSession();
         }
       }
 
-      function handleResult(err, sessionData) {
-        /*jshint validthis:true */
-        this._clearPendingCallTimer();
-
-        if (err) {
-          this.trigger("session:error", new Error(
-            "Retrieval of session information failed: HTTP " + err));
-          return;
-        }
+      // Setup pending call timeout.
+      this._pendingCallTimer = setTimeout(
+        handleOutgoingCallTimeout.bind(this), this.pendingCallTimeout);
 
-        if (options.outgoing) {
-          // Setup pending call timeout.
-          this._pendingCallTimer = setTimeout(
-            handleOutgoingCallTimeout.bind(this), this.pendingCallTimeout);
-        } else {
-          // XXX For incoming calls we might have more than one call queued.
-          // For now, we'll just assume the first call is the right information.
-          // We'll probably really want to be getting this data from the
-          // background worker on the desktop client.
-          // Bug 990714 should fix this.
-          sessionData = sessionData[0];
-        }
-
-        this.setReady(sessionData);
-      }
-
-      if (options.outgoing) {
-        options.client.requestCallInfo(this.get("loopToken"), options.callType,
-          handleResult.bind(this));
-      }
-      else {
-        options.client.requestCallsInfo(this.get("loopVersion"),
-          handleResult.bind(this));
-      }
+      this.setSessionData(sessionData);
+      this.trigger("call:outgoing");
     },
 
     /**
      * Checks that the session is ready.
      *
      * @return {Boolean}
      */
     isSessionReady: function() {
       return !!this.get("sessionId");
     },
 
     /**
-     * Sets session information and triggers the `session:ready` event.
+     * Sets session information.
      *
      * @param {Object} sessionData Conversation session information.
      */
-    setReady: function(sessionData) {
+    setSessionData: function(sessionData) {
       // Explicit property assignment to prevent later "surprises"
       this.set({
         sessionId:    sessionData.sessionId,
         sessionToken: sessionData.sessionToken,
         apiKey:       sessionData.apiKey
-      }).trigger("session:ready", this);
-      return this;
+      });
     },
 
     /**
      * Starts a SDK session and subscribe to call events.
      */
     startSession: function() {
       if (!this.isSessionReady()) {
         throw new Error("Can't start session as it's not ready");
--- a/browser/components/loop/content/shared/js/router.js
+++ b/browser/components/loop/content/shared/js/router.js
@@ -116,19 +116,22 @@ loop.shared.router = (function(l10n) {
      *
      * @param {Object} options Options object.
      */
     constructor: function(options) {
       options = options || {};
       if (!options.conversation) {
         throw new Error("missing required conversation");
       }
+      if (!options.client) {
+        throw new Error("missing required client");
+      }
       this._conversation = options.conversation;
+      this._client = options.client;
 
-      this.listenTo(this._conversation, "session:ready", this._onSessionReady);
       this.listenTo(this._conversation, "session:ended", this._onSessionEnded);
       this.listenTo(this._conversation, "session:peer-hungup",
                                         this._onPeerHungup);
       this.listenTo(this._conversation, "session:network-disconnected",
                                         this._onNetworkDisconnected);
       this.listenTo(this._conversation, "session:connection-error",
                     this._notifyError);
 
@@ -141,33 +144,21 @@ loop.shared.router = (function(l10n) {
      */
     _notifyError: function(error) {
       console.log(error);
       this._notifier.errorL10n("connection_error_see_console_notification");
       this.endCall();
     },
 
     /**
-     * Starts the call. This method should be overriden.
-     */
-    startCall: function() {},
-
-    /**
      * Ends the call. This method should be overriden.
      */
     endCall: function() {},
 
     /**
-     * Session is ready.
-     */
-    _onSessionReady: function() {
-      this.startCall();
-    },
-
-    /**
      * Session has ended. Notifies the user and ends the call.
      */
     _onSessionEnded: function() {
       this._notifier.warnL10n("call_has_ended");
       this.endCall();
     },
 
     /**
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -133,19 +133,19 @@ loop.shared.views = (function(_, OT, l10
       var prefix = this.props.enabled ? "mute" : "unmute";
       var suffix = "button_title";
       var msgId = [prefix, this.props.scope, this.props.type, suffix].join("_");
       return __(msgId);
     },
 
     render: function() {
       return (
-        React.DOM.button( {className:this._getClasses(),
-                title:this._getTitle(),
-                onClick:this.handleClick})
+        React.DOM.button({className: this._getClasses(), 
+                title: this._getTitle(), 
+                onClick: this.handleClick})
       );
     }
   });
 
   /**
    * Conversation controls.
    */
   var ConversationToolbar = React.createClass({displayName: 'ConversationToolbar',
@@ -172,26 +172,26 @@ loop.shared.views = (function(_, OT, l10
     },
 
     handleToggleAudio: function() {
       this.props.publishStream("audio", !this.props.audio.enabled);
     },
 
     render: function() {
       return (
-        React.DOM.ul( {className:"controls"}, 
-          React.DOM.li(null, React.DOM.button( {className:"btn btn-hangup",
-                      onClick:this.handleClickHangup,
-                      title:__("hangup_button_title")})),
-          React.DOM.li(null, MediaControlButton( {action:this.handleToggleVideo,
-                                  enabled:this.props.video.enabled,
-                                  scope:"local", type:"video"} )),
-          React.DOM.li(null, MediaControlButton( {action:this.handleToggleAudio,
-                                  enabled:this.props.audio.enabled,
-                                  scope:"local", type:"audio"} ))
+        React.DOM.ul({className: "controls"}, 
+          React.DOM.li(null, React.DOM.button({className: "btn btn-hangup", 
+                      onClick: this.handleClickHangup, 
+                      title: __("hangup_button_title")})), 
+          React.DOM.li(null, MediaControlButton({action: this.handleToggleVideo, 
+                                  enabled: this.props.video.enabled, 
+                                  scope: "local", type: "video"})), 
+          React.DOM.li(null, MediaControlButton({action: this.handleToggleAudio, 
+                                  enabled: this.props.audio.enabled, 
+                                  scope: "local", type: "audio"}))
         )
       );
     }
   });
 
   var ConversationView = React.createClass({displayName: 'ConversationView',
     mixins: [Backbone.Events],
 
@@ -324,24 +324,24 @@ loop.shared.views = (function(_, OT, l10
       // Unregister listeners for publisher events
       this.stopListening(this.publisher);
 
       this.props.model.session.unpublish(this.publisher);
     },
 
     render: function() {
       return (
-        React.DOM.div( {className:"conversation"}, 
-          ConversationToolbar( {video:this.state.video,
-                               audio:this.state.audio,
-                               publishStream:this.publishStream,
-                               hangup:this.hangup} ),
-          React.DOM.div( {className:"media nested"}, 
-            React.DOM.div( {className:"remote"}, React.DOM.div( {className:"incoming"})),
-            React.DOM.div( {className:"local"}, React.DOM.div( {className:"outgoing"}))
+        React.DOM.div({className: "conversation"}, 
+          ConversationToolbar({video: this.state.video, 
+                               audio: this.state.audio, 
+                               publishStream: this.publishStream, 
+                               hangup: this.hangup}), 
+          React.DOM.div({className: "media nested"}, 
+            React.DOM.div({className: "remote"}, React.DOM.div({className: "incoming"})), 
+            React.DOM.div({className: "local"}, React.DOM.div({className: "outgoing"}))
           )
         )
       );
     }
   });
 
   /**
    * Notification view.
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -27,27 +27,27 @@ loop.webapp = (function($, _, OT) {
   var HomeView = sharedViews.BaseView.extend({
     template: _.template('<p data-l10n-id="welcome"></p>')
   });
 
   /**
    * Conversation launcher view. A ConversationModel is associated and attached
    * as a `model` property.
    */
-  var ConversationFormView = sharedViews.BaseView.extend({
+  var StartConversationView = sharedViews.BaseView.extend({
     template: _.template([
       '<form>',
       '  <p>',
       '    <button class="btn btn-success" data-l10n-id="start_call"></button>',
       '  </p>',
       '</form>'
     ].join("")),
 
     events: {
-      "submit": "initiate"
+      "submit": "initiateOutgoingCall"
     },
 
     /**
      * Constructor.
      *
      * Required options:
      * - {loop.shared.model.ConversationModel}    model    Conversation model.
      * - {loop.shared.views.NotificationListView} notifier Notifier component.
@@ -84,27 +84,19 @@ loop.webapp = (function($, _, OT) {
       this.$("button").attr("disabled", "disabled");
     },
 
     /**
      * Initiates the call.
      *
      * @param {SubmitEvent} event
      */
-    initiate: function(event) {
+    initiateOutgoingCall: function(event) {
       event.preventDefault();
-      this.model.initiate({
-        client: new loop.StandaloneClient({
-          baseServerUrl: baseServerUrl,
-        }),
-        outgoing: true,
-        // For now, we assume both audio and video as there is no
-        // other option to select.
-        callType: "audio-video"
-      });
+      this.model.setupOutgoingCall();
       this.disableForm();
     }
   });
 
   /**
    * Webapp Router.
    */
   var WebappRouter = loop.shared.router.BaseConversationRouter.extend({
@@ -119,24 +111,61 @@ loop.webapp = (function($, _, OT) {
     initialize: function() {
       // Load default view
       this.loadView(new HomeView());
 
       this.listenTo(this._conversation, "timeout", this._onTimeout);
     },
 
     /**
-     * @override {loop.shared.router.BaseConversationRouter.startCall}
+     * Starts the set up of a call, obtaining the required information from the
+     * server.
      */
-    startCall: function() {
-      if (!this._conversation.get("loopToken")) {
+    setupOutgoingCall: function() {
+      var loopToken = this._conversation.get("loopToken");
+      if (!loopToken) {
         this._notifier.errorL10n("missing_conversation_info");
         this.navigate("home", {trigger: true});
       } else {
-        this.navigate("call/ongoing/" + this._conversation.get("loopToken"), {
+        this._conversation.once("call:outgoing", this.startCall, this);
+
+        // XXX For now, we assume both audio and video as there is no
+        // other option to select (bug 1048333)
+        this._client.requestCallInfo(this._conversation.get("loopToken"), "audio-video",
+                                     (err, sessionData) => {
+          if (err) {
+            switch (err.errno) {
+              // loop-server sends 404 + INVALID_TOKEN (errno 105) whenever a token is
+              // missing OR expired; we treat this information as if the url is always
+              // expired.
+              case 105:
+                this._onSessionExpired();
+                break;
+              default:
+                this._notifier.errorL10n("missing_conversation_info");
+                this.navigate("home", {trigger: true});
+                break;
+            }
+            return;
+          }
+          this._conversation.outgoing(sessionData);
+        });
+      }
+    },
+
+    /**
+     * Actually starts the call.
+     */
+    startCall: function() {
+      var loopToken = this._conversation.get("loopToken");
+      if (!loopToken) {
+        this._notifier.errorL10n("missing_conversation_info");
+        this.navigate("home", {trigger: true});
+      } else {
+        this.navigate("call/ongoing/" + loopToken, {
           trigger: true
         });
       }
     },
 
     /**
      * @override {loop.shared.router.BaseConversationRouter.endCall}
      */
@@ -175,20 +204,24 @@ loop.webapp = (function($, _, OT) {
      * @param  {String} loopToken Loop conversation token.
      */
     initiate: function(loopToken) {
       // Check if a session is ongoing; if so, terminate it
       if (this._conversation.get("ongoing")) {
         this._conversation.endSession();
       }
       this._conversation.set("loopToken", loopToken);
-      this.loadView(new ConversationFormView({
+
+      var startView = new StartConversationView({
         model: this._conversation,
-        notifier: this._notifier
-      }));
+        notifier: this._notifier,
+        client: this._client
+      });
+      this._conversation.once("call:outgoing:setup", this.setupOutgoingCall, this);
+      this.loadView(startView);
     },
 
     /**
      * Loads conversation establishment view.
      *
      */
     loadConversation: function(loopToken) {
       if (!this._conversation.isSessionReady()) {
@@ -213,32 +246,36 @@ loop.webapp = (function($, _, OT) {
     return this._iOSRegex.test(platform);
   };
 
   /**
    * App initialization.
    */
   function init() {
     var helper = new WebappHelper();
+    var client = new loop.StandaloneClient({
+      baseServerUrl: baseServerUrl
+    });
     router = new WebappRouter({
       notifier: new sharedViews.NotificationListView({el: "#messages"}),
+      client: client,
       conversation: new sharedModels.ConversationModel({}, {
         sdk: OT,
         pendingCallTimeout: loop.config.pendingCallTimeout
       })
     });
     Backbone.history.start();
     if (helper.isIOS(navigator.platform)) {
       router.navigate("unsupportedDevice", {trigger: true});
     } else if (!OT.checkSystemRequirements()) {
       router.navigate("unsupportedBrowser", {trigger: true});
     }
   }
 
   return {
     baseServerUrl: baseServerUrl,
-    ConversationFormView: ConversationFormView,
+    StartConversationView: StartConversationView,
     HomeView: HomeView,
     WebappHelper: WebappHelper,
     init: init,
     WebappRouter: WebappRouter
   };
 })(jQuery, _, window.OT);
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -97,35 +97,39 @@ describe("loop.conversation", function()
     it("should start Backbone history", function() {
       loop.conversation.init();
 
       sinon.assert.calledOnce(Backbone.history.start);
     });
   });
 
   describe("ConversationRouter", function() {
-    var conversation;
+    var conversation, client;
 
     beforeEach(function() {
+      client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
         sdk: {},
-        pendingCallTimeout: 1000
+        pendingCallTimeout: 1000,
       });
-      sandbox.stub(conversation, "initiate");
+      sandbox.stub(client, "requestCallsInfo");
+      sandbox.stub(conversation, "setSessionData");
     });
 
     describe("Routes", function() {
       var router;
 
       beforeEach(function() {
         router = new ConversationRouter({
+          client: client,
           conversation: conversation,
           notifier: notifier
         });
         sandbox.stub(router, "loadView");
+        sandbox.stub(conversation, "incoming");
       });
 
       describe("#incoming", function() {
 
         // XXX refactor to Just Work with "sandbox.stubComponent" or else
         // just pass in the sandbox and put somewhere generally usable
 
         function stubComponent(obj, component, mockTagName){
@@ -138,56 +142,88 @@ describe("loop.conversation", function()
           return sandbox.stub(obj, component, reactClass);
         }
 
         beforeEach(function() {
           sandbox.stub(router, "loadReactComponent");
           stubComponent(loop.conversation, "IncomingCallView");
         });
 
+        it("should start alerting", function() {
+          sandbox.stub(navigator.mozLoop, "startAlerting");
+          router.incoming("fakeVersion");
+
+          sinon.assert.calledOnce(navigator.mozLoop.startAlerting);
+        });
+
         it("should set the loopVersion on the conversation model", function() {
           router.incoming("fakeVersion");
 
           expect(conversation.get("loopVersion")).to.equal("fakeVersion");
         });
 
-        it("should display the incoming call view", function() {
+        it("should call requestCallsInfo on the client",
+          function() {
+            router.incoming(42);
+
+            sinon.assert.calledOnce(client.requestCallsInfo);
+            sinon.assert.calledWith(client.requestCallsInfo, 42);
+          });
+
+        it("should display an error if requestCallsInfo returns an error",
+          function(){
+            client.requestCallsInfo.callsArgWith(1, "failed");
+
+            router.incoming(42);
+
+            sinon.assert.calledOnce(notifier.errorL10n);
+          });
+
+        describe("requestCallsInfo successful", function() {
+          var fakeSessionData;
+
+          beforeEach(function() {
+            fakeSessionData  = {
+              sessionId:    "sessionId",
+              sessionToken: "sessionToken",
+              apiKey:       "apiKey"
+            };
+
+            client.requestCallsInfo.callsArgWith(1, null, [fakeSessionData]);
+          });
+
+          it("should store the session data", function() {
+            router.incoming(42);
+
+            sinon.assert.calledOnce(conversation.setSessionData);
+            sinon.assert.calledWithExactly(conversation.setSessionData,
+                                           fakeSessionData);
+          });
+
+          it("should display the incoming call view", function() {
             router.incoming("fakeVersion");
 
             sinon.assert.calledOnce(loop.conversation.IncomingCallView);
             sinon.assert.calledWithExactly(loop.conversation.IncomingCallView,
                                            {model: conversation});
             sinon.assert.calledOnce(router.loadReactComponent);
             sinon.assert.calledWith(router.loadReactComponent,
               sinon.match(function(value) {
                 return TestUtils.isComponentOfType(value,
                   loop.conversation.IncomingCallView);
               }));
-        });
-
-        it("should start alerting", function() {
-          sandbox.stub(window.navigator.mozLoop, "startAlerting");
-          router.incoming("fakeVersion");
-
-          sinon.assert.calledOnce(window.navigator.mozLoop.startAlerting);
+          });
         });
       });
 
       describe("#accept", function() {
         it("should initiate the conversation", function() {
           router.accept();
 
-          sinon.assert.calledOnce(conversation.initiate);
-          sinon.assert.calledWithMatch(conversation.initiate, {
-            client: {
-              mozLoop: navigator.mozLoop,
-              settings: {}
-            },
-            outgoing: false
-          });
+          sinon.assert.calledOnce(conversation.incoming);
         });
 
         it("should stop alerting", function() {
           sandbox.stub(window.navigator.mozLoop, "stopAlerting");
           router.accept();
 
           sinon.assert.calledOnce(window.navigator.mozLoop.stopAlerting);
         });
@@ -262,24 +298,27 @@ describe("loop.conversation", function()
           sessionId:    "sessionId",
           sessionToken: "sessionToken",
           apiKey:       "apiKey"
         };
         sandbox.stub(loop.conversation.ConversationRouter.prototype,
                      "navigate");
         conversation.set("loopToken", "fakeToken");
         router = new loop.conversation.ConversationRouter({
+          client: client,
           conversation: conversation,
           notifier: notifier
         });
       });
 
-      it("should navigate to call/ongoing once the call session is ready",
+      it("should navigate to call/ongoing once the call is ready",
         function() {
-          conversation.setReady(fakeSessionData);
+          router.incoming(42);
+
+          conversation.incoming();
 
           sinon.assert.calledOnce(router.navigate);
           sinon.assert.calledWith(router.navigate, "call/ongoing");
         });
 
       it("should navigate to call/ended when the call session ends",
         function() {
           conversation.trigger("session:ended");
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -73,137 +73,83 @@ describe("loop.shared.models", function(
         fakeClient = {
           requestCallInfo: sandbox.stub(),
           requestCallsInfo: sandbox.stub()
         };
         requestCallInfoStub = fakeClient.requestCallInfo;
         requestCallsInfoStub = fakeClient.requestCallsInfo;
       });
 
-      describe("#initiate", function() {
-        beforeEach(function() {
-          sandbox.stub(conversation, "endSession");
-        });
-
-        it("call requestCallInfo on the client for outgoing calls",
-          function() {
-            conversation.initiate({
-              client: fakeClient,
-              outgoing: true,
-              callType: "audio"
-            });
-
-            sinon.assert.calledOnce(requestCallInfoStub);
-            sinon.assert.calledWith(requestCallInfoStub, "fakeToken", "audio");
+      describe("#incoming", function() {
+        it("should trigger a `call:incoming` event", function(done) {
+          conversation.once("call:incoming", function() {
+            done();
           });
 
-        it("should not call requestCallsInfo on the client for outgoing calls",
-          function() {
-            conversation.initiate({
-              client: fakeClient,
-              outgoing: true,
-              callType: "audio"
-            });
-
-            sinon.assert.notCalled(requestCallsInfoStub);
+          conversation.incoming();
+        });
+      });
+      describe("#setupOutgoingCall", function() {
+        it("should trigger a `call:outgoing:setup` event", function(done) {
+          conversation.once("call:outgoing:setup", function() {
+            done();
           });
 
-        it("call requestCallsInfo on the client for incoming calls",
-          function() {
-            conversation.set("loopVersion", 42);
-            conversation.initiate({
-              client: fakeClient,
-              outgoing: false
-            });
+          conversation.setupOutgoingCall();
+        });
+      });
+
+      describe("#outgoing", function() {
+        beforeEach(function() {
+          sandbox.stub(conversation, "endSession");
+          sandbox.stub(conversation, "setSessionData");
+        });
 
-            sinon.assert.calledOnce(requestCallsInfoStub);
-            sinon.assert.calledWith(requestCallsInfoStub, 42);
-          });
+        it("should save the sessionData", function() {
+          conversation.outgoing(fakeSessionData);
 
-        it("should not call requestCallInfo on the client for incoming calls",
-          function() {
-            conversation.initiate({
-              client: fakeClient,
-              outgoing: false
-            });
+          sinon.assert.calledOnce(conversation.setSessionData);
+        });
 
-            sinon.assert.notCalled(requestCallInfoStub);
+        it("should trigger a `call:outgoing` event", function(done) {
+          conversation.once("call:outgoing", function() {
+            done();
           });
 
-        it("should update conversation session information from server data",
-          function() {
-            sandbox.stub(conversation, "setReady");
-            requestCallInfoStub.callsArgWith(2, null, fakeSessionData);
-
-            conversation.initiate({
-              client: fakeClient,
-              outgoing: true
-            });
-
-            sinon.assert.calledOnce(conversation.setReady);
-            sinon.assert.calledWith(conversation.setReady, fakeSessionData);
-          });
-
-        it("should trigger a `session:error` on failure", function(done) {
-          requestCallInfoStub.callsArgWith(2,
-            new Error("failed: HTTP 400 Bad Request; fake"));
-
-          conversation.on("session:error", function(err) {
-            expect(err.message).to.match(/failed: HTTP 400 Bad Request; fake/);
-            done();
-          }).initiate({
-            client: fakeClient,
-            outgoing: true
-          });
+          conversation.outgoing();
         });
 
         it("should end the session on outgoing call timeout", function() {
-          requestCallInfoStub.callsArgWith(2, null, fakeSessionData);
-
-          conversation.initiate({
-            client: fakeClient,
-            outgoing: true
-          });
+          conversation.outgoing();
 
           sandbox.clock.tick(1001);
 
           sinon.assert.calledOnce(conversation.endSession);
         });
 
         it("should trigger a `timeout` event on outgoing call timeout",
           function(done) {
-            requestCallInfoStub.callsArgWith(2, null, fakeSessionData);
-
             conversation.once("timeout", function() {
               done();
             });
 
-            conversation.initiate({
-              client: fakeClient,
-              outgoing: true
-            });
+            conversation.outgoing();
 
             sandbox.clock.tick(1001);
           });
       });
 
-      describe("#setReady", function() {
+      describe("#setSessionData", function() {
         it("should update conversation session information", function() {
-          conversation.setReady(fakeSessionData);
+          conversation.setSessionData(fakeSessionData);
 
           expect(conversation.get("sessionId")).eql("sessionId");
           expect(conversation.get("sessionToken")).eql("sessionToken");
           expect(conversation.get("apiKey")).eql("apiKey");
         });
-
-        it("should trigger a `session:ready` event", function(done) {
-          conversation.on("session:ready", function() {
-            done();
-          }).setReady(fakeSessionData);
-        });
       });
 
       describe("#startSession", function() {
         var model;
 
         beforeEach(function() {
           sandbox.stub(sharedModels.ConversationModel.prototype,
                        "_clearPendingCallTimer");
--- a/browser/components/loop/test/shared/router_test.js
+++ b/browser/components/loop/test/shared/router_test.js
@@ -92,47 +92,52 @@ describe("loop.shared.router", function(
     });
   });
 
   describe("BaseConversationRouter", function() {
     var conversation, TestRouter;
 
     beforeEach(function() {
       TestRouter = loop.shared.router.BaseConversationRouter.extend({
-        startCall: sandbox.spy(),
         endCall: sandbox.spy()
       });
       conversation = new loop.shared.models.ConversationModel({
         loopToken: "fakeToken"
       }, {
         sdk: {},
         pendingCallTimeout: 1000
       });
     });
 
     describe("#constructor", function() {
       it("should require a ConversationModel instance", function() {
         expect(function() {
-          new TestRouter();
+          new TestRouter({ client: {} });
         }).to.Throw(Error, /missing required conversation/);
       });
+      it("should require a Client instance", function() {
+        expect(function() {
+          new TestRouter({ conversation: {} });
+        }).to.Throw(Error, /missing required client/);
+      });
     });
 
     describe("Events", function() {
       var router, fakeSessionData;
 
       beforeEach(function() {
         fakeSessionData = {
           sessionId:    "sessionId",
           sessionToken: "sessionToken",
           apiKey:       "apiKey"
         };
         router = new TestRouter({
           conversation: conversation,
-          notifier: notifier
+          notifier: notifier,
+          client: {}
         });
       });
 
       describe("session:connection-error", function() {
 
         it("should warn the user when .connect() call fails", function() {
           conversation.trigger("session:connection-error");
 
@@ -144,22 +149,16 @@ describe("loop.shared.router", function(
           conversation.trigger("session:connection-error");
 
           sinon.assert.calledOnce(router.endCall);
           sinon.assert.calledWithExactly(router.endCall);
         });
 
       });
 
-      it("should call startCall() once the call session is ready", function() {
-        conversation.trigger("session:ready");
-
-        sinon.assert.calledOnce(router.startCall);
-      });
-
       it("should call endCall() when conversation ended", function() {
         conversation.trigger("session:ended");
 
         sinon.assert.calledOnce(router.endCall);
       });
 
       it("should warn the user that the session has ended", function() {
         conversation.trigger("session:ended");
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -11,16 +11,19 @@ describe("loop.webapp", function() {
 
   var sharedModels = loop.shared.models,
       sharedViews = loop.shared.views,
       sandbox,
       notifier;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
+    // conversation#outgoing sets timers, so we need to use fake ones
+    // to prevent random failures.
+    sandbox.useFakeTimers();
     notifier = {
       notify: sandbox.spy(),
       warn: sandbox.spy(),
       warnL10n: sandbox.spy(),
       error: sandbox.spy(),
       errorL10n: sandbox.spy(),
     };
     loop.config.pendingCallTimeout = 1000;
@@ -63,25 +66,30 @@ describe("loop.webapp", function() {
 
       sinon.assert.calledOnce(WebappRouter.prototype.navigate);
       sinon.assert.calledWithExactly(WebappRouter.prototype.navigate,
                                      "unsupportedBrowser", {trigger: true});
     });
   });
 
   describe("WebappRouter", function() {
-    var router, conversation;
+    var router, conversation, client;
 
     beforeEach(function() {
+      client = new loop.StandaloneClient({
+        baseServerUrl: "http://fake.example.com"
+      });
+      sandbox.stub(client, "requestCallInfo");
       conversation = new sharedModels.ConversationModel({}, {
         sdk: {},
         pendingCallTimeout: 1000
       });
       router = new loop.webapp.WebappRouter({
         conversation: conversation,
+        client: client,
         notifier: notifier
       });
       sandbox.stub(router, "loadView");
       sandbox.stub(router, "loadReactComponent");
       sandbox.stub(router, "navigate");
     });
 
     describe("#startCall", function() {
@@ -142,22 +150,22 @@ describe("loop.webapp", function() {
 
       describe("#initiate", function() {
         it("should set the token on the conversation model", function() {
           router.initiate("fakeToken");
 
           expect(conversation.get("loopToken")).eql("fakeToken");
         });
 
-        it("should load the ConversationFormView", function() {
+        it("should load the StartConversationView", function() {
           router.initiate("fakeToken");
 
           sinon.assert.calledOnce(router.loadView);
           sinon.assert.calledWith(router.loadView,
-            sinon.match.instanceOf(loop.webapp.ConversationFormView));
+            sinon.match.instanceOf(loop.webapp.StartConversationView));
         });
 
         // https://bugzilla.mozilla.org/show_bug.cgi?id=991118
         it("should terminate any ongoing call session", function() {
           sinon.stub(conversation, "endSession");
           conversation.set("ongoing", true);
 
           router.initiate("fakeToken");
@@ -219,17 +227,18 @@ describe("loop.webapp", function() {
           sessionToken: "sessionToken",
           apiKey:       "apiKey"
         };
         conversation.set("loopToken", "fakeToken");
       });
 
       it("should navigate to call/ongoing/:token once call session is ready",
         function() {
-          conversation.trigger("session:ready");
+          router.setupOutgoingCall();
+          conversation.outgoing(fakeSessionData);
 
           sinon.assert.calledOnce(router.navigate);
           sinon.assert.calledWith(router.navigate, "call/ongoing/fakeToken");
         });
 
       it("should navigate to call/{token} when conversation ended", function() {
         conversation.trigger("session:ended");
 
@@ -246,20 +255,83 @@ describe("loop.webapp", function() {
 
       it("should navigate to call/{token} when network disconnects",
         function() {
           conversation.trigger("session:network-disconnected");
 
           sinon.assert.calledOnce(router.navigate);
           sinon.assert.calledWithMatch(router.navigate, "call/fakeToken");
         });
+
+      describe("#setupOutgoingCall", function() {
+        beforeEach(function() {
+          router.initiate();
+        });
+
+        describe("No loop token", function() {
+          it("should navigate to home", function() {
+            conversation.setupOutgoingCall();
+
+            sinon.assert.calledOnce(router.navigate);
+            sinon.assert.calledWithMatch(router.navigate, "home");
+          });
+
+          it("should display an error", function() {
+            conversation.setupOutgoingCall();
+
+            sinon.assert.calledOnce(notifier.errorL10n);
+          });
+        });
+
+        describe("Has loop token", function() {
+          beforeEach(function() {
+            conversation.set("loopToken", "fakeToken");
+            sandbox.stub(conversation, "outgoing");
+          });
+
+          it("should call requestCallInfo on the client",
+            function() {
+              conversation.setupOutgoingCall();
+
+              sinon.assert.calledOnce(client.requestCallInfo);
+              sinon.assert.calledWith(client.requestCallInfo, "fakeToken",
+                                      "audio-video");
+            });
+
+          describe("requestCallInfo response handling", function() {
+            it("should navigate to home on any other error", function() {
+              client.requestCallInfo.callsArgWith(2, {errno: 104});
+              conversation.setupOutgoingCall();
+
+              sinon.assert.calledOnce(router.navigate);
+              sinon.assert.calledWith(router.navigate, "home");
+              });
+
+            it("should notify the user on any other error", function() {
+              client.requestCallInfo.callsArgWith(2, {errno: 104});
+              conversation.setupOutgoingCall();
+
+              sinon.assert.calledOnce(notifier.errorL10n);
+            });
+
+            it("should call outgoing on the conversation model when details " +
+               "are successfully received", function() {
+                client.requestCallInfo.callsArgWith(2, null, fakeSessionData);
+                conversation.setupOutgoingCall();
+
+                sinon.assert.calledOnce(conversation.outgoing);
+                sinon.assert.calledWithExactly(conversation.outgoing, fakeSessionData);
+              });
+          });
+        });
+      });
     });
   });
 
-  describe("ConversationFormView", function() {
+  describe("StartConversationView", function() {
     var conversation;
 
     beforeEach(function() {
       conversation = new sharedModels.ConversationModel({}, {
         sdk: {},
         pendingCallTimeout: 1000});
     });
 
@@ -267,66 +339,60 @@ describe("loop.webapp", function() {
       it("should require a conversation option", function() {
         expect(function() {
           new loop.webapp.WebappRouter();
         }).to.Throw(Error, /missing required conversation/);
       });
     });
 
     describe("#initiate", function() {
-      var conversation, initiate, view, fakeSubmitEvent;
+      var conversation, setupOutgoingCall, view, fakeSubmitEvent;
 
       beforeEach(function() {
         conversation = new sharedModels.ConversationModel({}, {
           sdk: {},
           pendingCallTimeout: 1000
         });
-        view = new loop.webapp.ConversationFormView({
+        view = new loop.webapp.StartConversationView({
           model: conversation,
           notifier: notifier
         });
         fakeSubmitEvent = {preventDefault: sinon.spy()};
-        initiate = sinon.stub(conversation, "initiate");
+        setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
       });
 
       it("should start the conversation establishment process", function() {
         conversation.set("loopToken", "fake");
 
-        view.initiate(fakeSubmitEvent);
+        view.initiateOutgoingCall(fakeSubmitEvent);
 
-        sinon.assert.calledOnce(fakeSubmitEvent.preventDefault);
-        sinon.assert.calledOnce(initiate);
-        sinon.assert.calledWith(initiate, sinon.match(function (value) {
-          return !!value.outgoing &&
-            (value.client instanceof loop.StandaloneClient) &&
-            value.client.settings.baseServerUrl === loop.webapp.baseServerUrl;
-        }, "{client: <properly constructed client>, outgoing: true}"));
+        sinon.assert.calledOnce(setupOutgoingCall);
       });
 
       it("should disable current form once session is initiated", function() {
         sandbox.stub(view, "disableForm");
         conversation.set("loopToken", "fake");
 
-        view.initiate(fakeSubmitEvent);
+        view.initiateOutgoingCall(fakeSubmitEvent);
 
         sinon.assert.calledOnce(view.disableForm);
       });
     });
 
     describe("Events", function() {
       var conversation, view;
 
       beforeEach(function() {
         conversation = new sharedModels.ConversationModel({
           loopToken: "fake"
         }, {
           sdk: {},
           pendingCallTimeout: 1000
         });
-        view = new loop.webapp.ConversationFormView({
+        view = new loop.webapp.StartConversationView({
           model: conversation,
           notifier: notifier
         });
       });
 
       it("should trigger a notication when a session:error model event is " +
          " received", function() {
         conversation.trigger("session:error", "tech error");