Bug 1000237 Standalone UI for link clickers needs "call being processed" visual notification. r=nperriault
authorMark Banner <standard8@mozilla.com>
Fri, 12 Sep 2014 13:57:19 +0100
changeset 228267 3932a0c7f8e54c9586b6a410ee578d1a5a5c6d14
parent 228266 7b7a4bb58bff45456f0f8fd40d363f50f9bf8e62
child 228268 3b16c8fd53e89a9c1341931049c571898c438467
push id1
push usersledru@mozilla.com
push dateThu, 04 Dec 2014 17:57:20 +0000
reviewersnperriault
bugs1000237
milestone35.0a1
Bug 1000237 Standalone UI for link clickers needs "call being processed" visual notification. r=nperriault
browser/components/loop/content/shared/css/common.css
browser/components/loop/content/shared/js/models.js
browser/components/loop/content/shared/js/websocket.js
browser/components/loop/standalone/Makefile
browser/components/loop/standalone/content/css/webapp.css
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/standalone/content/l10n/loop.en-US.properties
browser/components/loop/standalone/server.js
browser/components/loop/test/shared/models_test.js
browser/components/loop/test/shared/websocket_test.js
browser/components/loop/test/standalone/webapp_test.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -130,30 +130,33 @@ p {
     background-color: #64a43a;
     border: 1px solid #64a43a;
   }
 
 .btn-warning {
   background-color: #f0ad4e;
 }
 
+.btn-cancel,
 .btn-error,
 .btn-hangup,
 .btn-error + .btn-chevron {
   background-color: #d74345;
   border: 1px solid #d74345;
 }
 
+  .btn-cancel:hover,
   .btn-error:hover,
   .btn-hangup:hover,
   .btn-error + .btn-chevron:hover {
     background-color: #c53436;
     border: 1px solid #c53436;
   }
 
+  .btn-cancel:active,
   .btn-error:active,
   .btn-hangup:active,
   .btn-error + .btn-chevron:active {
     background-color: #ae2325;
     border: 1px solid #ae2325;
   }
 
 .btn-chevron {
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -47,52 +47,32 @@ loop.shared.models = (function(l10n) {
 
     /**
      * SDK session object.
      * @type {XXX}
      */
     session: undefined,
 
     /**
-     * Pending call timeout value.
-     * @type {Number}
-     */
-    pendingCallTimeout: undefined,
-
-    /**
-     * Pending call timer.
-     * @type {Number}
-     */
-    _pendingCallTimer: undefined,
-
-    /**
      * Constructor.
      *
      * Options:
      *
      * Required:
      * - {OT} sdk: OT SDK object.
      *
-     * Optional:
-     * - {Number} pendingCallTimeout: Pending call timeout in milliseconds
-     *                                (default: 20000).
-     *
      * @param  {Object} attributes Attributes object.
      * @param  {Object} options    Options object.
      */
     initialize: function(attributes, options) {
       options = options || {};
       if (!options.sdk) {
         throw new Error("missing required sdk");
       }
       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);
     },
 
     /**
      * Starts an incoming conversation.
      */
     incoming: function() {
       this.trigger("call:incoming");
     },
@@ -107,30 +87,16 @@ loop.shared.models = (function(l10n) {
 
     /**
      * Starts an outgoing conversation.
      *
      * @param {Object} sessionData The session data received from the
      *                             server for the outgoing call.
      */
     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();
-        }
-      }
-
-      // Setup pending call timeout.
-      this._pendingCallTimer = setTimeout(
-        handleOutgoingCallTimeout.bind(this), this.pendingCallTimeout);
-
       this.setOutgoingSessionData(sessionData);
       this.trigger("call:outgoing");
     },
 
     /**
      * Checks that the session is ready.
      *
      * @return {Boolean}
@@ -274,25 +240,16 @@ loop.shared.models = (function(l10n) {
           break;
         default:
           this.trigger("session:error", err);
           break;
       }
     },
 
     /**
-     * Clears current pending call timer, if any.
-     */
-    _clearPendingCallTimer: function() {
-      if (this._pendingCallTimer) {
-        clearTimeout(this._pendingCallTimer);
-      }
-    },
-
-    /**
      * Manages connection status
      * triggers apropriate event for connection error/success
      * http://tokbox.com/opentok/tutorials/connect-session/js/
      * http://tokbox.com/opentok/tutorials/hello-world/js/
      * http://tokbox.com/opentok/libraries/client/js/reference/SessionConnectEvent.html
      *
      * @param {error|null} error
      */
--- a/browser/components/loop/content/shared/js/websocket.js
+++ b/browser/components/loop/content/shared/js/websocket.js
@@ -144,16 +144,28 @@ loop.CallConnectionWebSocket = (function
     mediaUp: function() {
       this._send({
         messageType: "action",
         event: "media-up"
       });
     },
 
     /**
+     * Notifies the server that the outgoing call is cancelled by the
+     * user.
+     */
+    cancel: function() {
+      this._send({
+        messageType: "action",
+        event: "terminate",
+        reason: "cancel"
+      });
+    },
+
+    /**
      * Sends data on the websocket.
      *
      * @param {Object} data The data to send.
      */
     _send: function(data) {
       this._log("WS Sending", data);
 
       this.socket.send(JSON.stringify(data));
@@ -201,16 +213,17 @@ loop.CallConnectionWebSocket = (function
 
       this._lastServerState = msg.state;
 
       switch(msg.messageType) {
         case "hello":
           this._completeConnection();
           break;
         case "progress":
+          this.trigger("progress:" + msg.state);
           this.trigger("progress", msg);
           break;
       }
     },
 
     /**
      * Called when there is an error on the websocket.
      *
--- a/browser/components/loop/standalone/Makefile
+++ b/browser/components/loop/standalone/Makefile
@@ -1,14 +1,13 @@
 # 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/.
 
 LOOP_SERVER_URL := $(shell echo $${LOOP_SERVER_URL-http://localhost:5000})
-LOOP_PENDING_CALL_TIMEOUT := $(shell echo $${LOOP_PENDING_CALL_TIMEOUT-20000})
 NODE_LOCAL_BIN=./node_modules/.bin
 
 install:
 	@npm install
 
 test:
 	@echo "Not implemented yet."
 
@@ -48,9 +47,8 @@ remove_old_config:
 # The services development deployment, however, still wants a static config
 # file, and needs an easy way to generate one.  This target is for folks
 # working with that deployment.
 .PHONY: config
 config:
 	@echo "var loop = loop || {};" > content/config.js
 	@echo "loop.config = loop.config || {};" >> content/config.js
 	@echo "loop.config.serverUrl          = '`echo $(LOOP_SERVER_URL)`';" >> content/config.js
-	@echo "loop.config.pendingCallTimeout = `echo $(LOOP_PENDING_CALL_TIMEOUT)`;" >> content/config.js
--- a/browser/components/loop/standalone/content/css/webapp.css
+++ b/browser/components/loop/standalone/content/css/webapp.css
@@ -16,19 +16,22 @@ body,
   color: #666;
   text-align: center;
   font-family: Open Sans,sans-serif;
 }
 
 .standalone-header {
   border-radius: 4px;
   background: #fff;
-  padding: 1rem 5rem;
   border: 1px solid #E7E7E7;
   box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.03);
+}
+
+.header-box {
+  padding: 1rem 5rem;
   margin-top: 2rem;
 }
 
 /*
  * Top/Bottom spacing
  **/
 .standalone-footer {
   margin-bottom: 2rem;
@@ -98,26 +101,26 @@ body,
   height: 100px;
   margin: 1rem auto;
   background-image: url("../shared/img/firefox-logo.png");
   background-size: cover;
   background-repeat: no-repeat;
 }
 
 .standalone-header-title,
-.standalone-call-btn-label {
+.standalone-btn-label {
   font-weight: lighter;
 }
 
 .standalone-header-title {
   font-size: 1.8rem;
   line-height: 2.2rem;
 }
 
-.standalone-call-btn-label {
+.standalone-btn-label {
   font-size: 1.2rem;
 }
 
 .light-color-font {
   opacity: .4;
   font-weight: normal;
 }
 
@@ -174,16 +177,20 @@ body,
   .start-audio-only-call:hover {
     background-image: url("../shared/img/audio-inverse-14x14@2x.png");
   }
   .standalone-call-btn-video-icon {
     background-image: url("../shared/img/video-inverse-14x14@2x.png");
   }
 }
 
+.btn-pending-cancel-group > .btn-cancel {
+  flex: 2 1 auto;
+}
+
 .btn-large {
   /* Dimensions from spec
    * https://people.mozilla.org/~dhenein/labs/loop-link-spec/#call-start */
   font-size: 1rem;
   padding: .3em .5rem;
 }
 
   .btn-large + .btn-chevron {
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -115,16 +115,26 @@ loop.webapp = (function($, _, OT, mozL10
           ), 
           PromoteFirefoxView({helper: this.props.helper})
         )
       );
       /* jshint ignore:end */
     }
   });
 
+  var ConversationBranding = React.createClass({displayName: 'ConversationBranding',
+    render: function() {
+      return (
+        React.DOM.h1({className: "standalone-header-title"}, 
+          React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
+        )
+      );
+    }
+  });
+
   var ConversationHeader = React.createClass({displayName: 'ConversationHeader',
     render: function() {
       var cx = React.addons.classSet;
       var conversationUrl = location.href;
 
       var urlCreationDateClasses = cx({
         "light-color-font": true,
         "call-url-date": true, /* Used as a handler in the tests */
@@ -133,20 +143,18 @@ loop.webapp = (function($, _, OT, mozL10
       });
 
       var callUrlCreationDateString = mozL10n.get("call_url_creation_date_label", {
         "call_url_creation_date": this.props.urlCreationDateString
       });
 
       return (
         /* jshint ignore:start */
-        React.DOM.header({className: "standalone-header container-box"}, 
-          React.DOM.h1({className: "standalone-header-title"}, 
-            React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
-          ), 
+        React.DOM.header({className: "standalone-header header-box container-box"}, 
+          ConversationBranding(null), 
           React.DOM.div({className: "loop-logo", title: "Firefox WebRTC! logo"}), 
           React.DOM.h3({className: "call-url"}, 
             conversationUrl
           ), 
           React.DOM.h4({className: urlCreationDateClasses}, 
             callUrlCreationDateString
           )
         )
@@ -160,16 +168,78 @@ loop.webapp = (function($, _, OT, mozL10
       return (
         React.DOM.div({className: "standalone-footer container-box"}, 
           React.DOM.div({title: "Mozilla Logo", className: "footer-logo"})
         )
       );
     }
   });
 
+  var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
+    getInitialState: function() {
+      return {
+        callState: this.props.callState || "connecting"
+      }
+    },
+
+    propTypes: {
+      websocket: React.PropTypes.instanceOf(loop.CallConnectionWebSocket)
+                      .isRequired
+    },
+
+    componentDidMount: function() {
+      this.props.websocket.listenTo(this.props.websocket, "progress:alerting",
+                                    this._handleRingingProgress);
+    },
+
+    _handleRingingProgress: function() {
+      this.setState({callState: "ringing"});
+    },
+
+    _cancelOutgoingCall: function() {
+      this.props.websocket.cancel();
+    },
+
+    render: function() {
+      var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
+      return (
+        /* jshint ignore:start */
+        React.DOM.div({className: "container"}, 
+          React.DOM.div({className: "container-box"}, 
+            React.DOM.header({className: "pending-header header-box"}, 
+              ConversationBranding(null)
+            ), 
+
+            React.DOM.div({id: "cameraPreview"}), 
+
+            React.DOM.div({id: "messages"}), 
+
+            React.DOM.p({className: "standalone-btn-label"}, 
+              callState
+            ), 
+
+            React.DOM.div({className: "btn-pending-cancel-group btn-group"}, 
+              React.DOM.div({className: "flex-padding-1"}), 
+              React.DOM.button({className: "btn btn-large btn-cancel", 
+                      onClick: this._cancelOutgoingCall}, 
+                React.DOM.span({className: "standalone-call-btn-text"}, 
+                  mozL10n.get("initiate_call_cancel_button")
+                )
+              ), 
+              React.DOM.div({className: "flex-padding-1"})
+            )
+          ), 
+
+          ConversationFooter(null)
+        )
+        /* jshint ignore:end */
+      );
+    }
+  });
+
   /**
    * Conversation launcher view. A ConversationModel is associated and attached
    * as a `model` property.
    */
   var StartConversationView = React.createClass({displayName: 'StartConversationView',
     /**
      * Constructor.
      *
@@ -281,17 +351,17 @@ loop.webapp = (function($, _, OT, mozL10
       return (
         /* jshint ignore:start */
         React.DOM.div({className: "container"}, 
           React.DOM.div({className: "container-box"}, 
 
             ConversationHeader({
               urlCreationDateString: this.state.urlCreationDateString}), 
 
-            React.DOM.p({className: "standalone-call-btn-label"}, 
+            React.DOM.p({className: "standalone-btn-label"}, 
               mozL10n.get("initiate_call_button_label2")
             ), 
 
             React.DOM.div({id: "messages"}), 
 
             React.DOM.div({className: "btn-group"}, 
               React.DOM.div({className: "flex-padding-1"}), 
               React.DOM.div({className: "standalone-btn-chevron-menu-group"}, 
@@ -347,30 +417,29 @@ loop.webapp = (function($, _, OT, mozL10
    * Webapp Router.
    */
   var WebappRouter = loop.shared.router.BaseConversationRouter.extend({
     routes: {
       "":                    "home",
       "unsupportedDevice":   "unsupportedDevice",
       "unsupportedBrowser":  "unsupportedBrowser",
       "call/expired":        "expired",
+      "call/pending/:token": "pendingConversation",
       "call/ongoing/:token": "loadConversation",
       "call/:token":         "initiate"
     },
 
     initialize: function(options) {
       this.helper = options.helper;
       if (!this.helper) {
         throw new Error("WebappRouter requires a helper object");
       }
 
       // Load default view
       this.loadReactComponent(HomeView(null));
-
-      this.listenTo(this._conversation, "timeout", this._onTimeout);
     },
 
     _onSessionExpired: function() {
       this.navigate("/call/expired", {trigger: true});
     },
 
     /**
      * Starts the set up of a call, obtaining the required information from the
@@ -412,36 +481,35 @@ loop.webapp = (function($, _, OT, mozL10
      * Actually starts the call.
      */
     startCall: function() {
       var loopToken = this._conversation.get("loopToken");
       if (!loopToken) {
         this._notifications.errorL10n("missing_conversation_info");
         this.navigate("home", {trigger: true});
       } else {
-        this._setupWebSocketAndCallView(loopToken);
+        this.navigate("call/pending/" + loopToken, {
+          trigger: true
+        });
       }
     },
 
     /**
      * Used to set up the web socket connection and navigate to the
      * call view if appropriate.
      *
      * @param {string} loopToken The session token to use.
      */
-    _setupWebSocketAndCallView: function(loopToken) {
+    _setupWebSocketAndCallView: function() {
       this._websocket = new loop.CallConnectionWebSocket({
         url: this._conversation.get("progressURL"),
         websocketToken: this._conversation.get("websocketToken"),
         callId: this._conversation.get("callId"),
       });
       this._websocket.promiseConnect().then(function() {
-        this.navigate("call/ongoing/" + loopToken, {
-          trigger: true
-        });
       }.bind(this), function() {
         // XXX Not the ideal response, but bug 1047410 will be replacing
         // this by better "call failed" UI.
         this._notifications.errorL10n("cannot_start_call_session_not_ready");
         return;
       }.bind(this));
 
       this._websocket.on("progress", this._handleWebSocketProgress, this);
@@ -459,57 +527,71 @@ loop.webapp = (function($, _, OT, mozL10
       }
     },
 
     /**
      * Used to receive websocket progress and to determine how to handle
      * it if appropraite.
      */
     _handleWebSocketProgress: function(progressData) {
-      if (progressData.state === "terminated") {
-        // XXX Before adding more states here, the basic protocol messages to the
-        // server need implementing on both the standalone and desktop side.
-        // These are covered by bug 1045643, but also check the dependencies on
-        // bug 1034041.
-        //
-        // Failure to do this will break desktop - standalone call setup. We're
-        // ok to handle reject, as that is a specific message from the destkop via
-        // the server.
-        switch (progressData.reason) {
-          case "reject":
-            this._handleCallRejected();
+      switch(progressData.state) {
+        case "connecting": {
+          this._handleCallConnecting();
+          break;
+        }
+        case "terminated": {
+          // At the moment, we show the same text regardless
+          // of the terminated reason.
+          this._handleCallTerminated(progressData.reason);
+          break;
         }
       }
     },
 
     /**
-     * Handles call rejection.
-     * XXX This should really display the call failed view - bug 1046959
-     * will implement this.
+     * Handles a call moving to the connecting stage.
      */
-    _handleCallRejected: function() {
+    _handleCallConnecting: function() {
+      var loopToken = this._conversation.get("loopToken");
+      if (!loopToken) {
+        this._notifications.errorL10n("missing_conversation_info");
+        return;
+      }
+
+      this.navigate("call/ongoing/" + loopToken, {
+        trigger: true
+      });
+    },
+
+    /**
+     * Handles call rejection.
+     *
+     * @param {String} reason The reason the call was terminated.
+     */
+    _handleCallTerminated: function(reason) {
       this.endCall();
-      this._notifications.errorL10n("call_timeout_notification_text");
+      // For reasons other than cancel, display some notification text.
+      if (reason !== "cancel") {
+        // XXX This should really display the call failed view - bug 1046959
+        // will implement this.
+        this._notifications.errorL10n("call_timeout_notification_text");
+      }
     },
 
     /**
      * @override {loop.shared.router.BaseConversationRouter.endCall}
      */
     endCall: function() {
       var route = "home";
       if (this._conversation.get("loopToken")) {
         route = "call/" + this._conversation.get("loopToken");
       }
       this.navigate(route, {trigger: true});
     },
 
-    _onTimeout: function() {
-      this._notifications.errorL10n("call_timeout_notification_text");
-    },
-
     /**
      * Default entry point.
      */
     home: function() {
       this.loadReactComponent(HomeView(null));
     },
 
     unsupportedDevice: function() {
@@ -544,16 +626,27 @@ loop.webapp = (function($, _, OT, mozL10
         client: this._client
       });
       this._conversation.once("call:outgoing:setup", this.setupOutgoingCall, this);
       this._conversation.once("change:publishedStream", this._checkConnected, this);
       this._conversation.once("change:subscribedStream", this._checkConnected, this);
       this.loadReactComponent(startView);
     },
 
+    pendingConversation: function(loopToken) {
+      if (!this._conversation.isSessionReady()) {
+        // User has loaded this url directly, actually setup the call.
+        return this.navigate("call/" + loopToken, {trigger: true});
+      }
+      this._setupWebSocketAndCallView();
+      this.loadReactComponent(PendingConversationView({
+        websocket: this._websocket
+      }));
+    },
+
     /**
      * Loads conversation establishment view.
      *
      */
     loadConversation: function(loopToken) {
       if (!this._conversation.isSessionReady()) {
         // User has loaded this url directly, actually setup the call.
         return this.navigate("call/" + loopToken, {trigger: true});
@@ -591,18 +684,17 @@ loop.webapp = (function($, _, OT, mozL10
     var client = new loop.StandaloneClient({
       baseServerUrl: baseServerUrl
     });
     var router = new WebappRouter({
       helper: helper,
       notifications: new sharedModels.NotificationCollection(),
       client: client,
       conversation: new sharedModels.ConversationModel({}, {
-        sdk: OT,
-        pendingCallTimeout: loop.config.pendingCallTimeout
+        sdk: OT
       })
     });
 
     Backbone.history.start();
     if (helper.isIOS(navigator.platform)) {
       router.navigate("unsupportedDevice", {trigger: true});
     } else if (!OT.checkSystemRequirements()) {
       router.navigate("unsupportedBrowser", {trigger: true});
@@ -611,16 +703,17 @@ loop.webapp = (function($, _, OT, mozL10
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
   }
 
   return {
     baseServerUrl: baseServerUrl,
     CallUrlExpiredView: CallUrlExpiredView,
+    PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
     WebappHelper: WebappHelper,
     WebappRouter: WebappRouter
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -115,16 +115,26 @@ loop.webapp = (function($, _, OT, mozL10
           </div>
           <PromoteFirefoxView helper={this.props.helper} />
         </div>
       );
       /* jshint ignore:end */
     }
   });
 
+  var ConversationBranding = React.createClass({
+    render: function() {
+      return (
+        <h1 className="standalone-header-title">
+          <strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
+        </h1>
+      );
+    }
+  });
+
   var ConversationHeader = React.createClass({
     render: function() {
       var cx = React.addons.classSet;
       var conversationUrl = location.href;
 
       var urlCreationDateClasses = cx({
         "light-color-font": true,
         "call-url-date": true, /* Used as a handler in the tests */
@@ -133,20 +143,18 @@ loop.webapp = (function($, _, OT, mozL10
       });
 
       var callUrlCreationDateString = mozL10n.get("call_url_creation_date_label", {
         "call_url_creation_date": this.props.urlCreationDateString
       });
 
       return (
         /* jshint ignore:start */
-        <header className="standalone-header container-box">
-          <h1 className="standalone-header-title">
-            <strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
-          </h1>
+        <header className="standalone-header header-box container-box">
+          <ConversationBranding />
           <div className="loop-logo" title="Firefox WebRTC! logo"></div>
           <h3 className="call-url">
             {conversationUrl}
           </h3>
           <h4 className={urlCreationDateClasses} >
             {callUrlCreationDateString}
           </h4>
         </header>
@@ -160,16 +168,78 @@ loop.webapp = (function($, _, OT, mozL10
       return (
         <div className="standalone-footer container-box">
           <div title="Mozilla Logo" className="footer-logo"></div>
         </div>
       );
     }
   });
 
+  var PendingConversationView = React.createClass({
+    getInitialState: function() {
+      return {
+        callState: this.props.callState || "connecting"
+      }
+    },
+
+    propTypes: {
+      websocket: React.PropTypes.instanceOf(loop.CallConnectionWebSocket)
+                      .isRequired
+    },
+
+    componentDidMount: function() {
+      this.props.websocket.listenTo(this.props.websocket, "progress:alerting",
+                                    this._handleRingingProgress);
+    },
+
+    _handleRingingProgress: function() {
+      this.setState({callState: "ringing"});
+    },
+
+    _cancelOutgoingCall: function() {
+      this.props.websocket.cancel();
+    },
+
+    render: function() {
+      var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
+      return (
+        /* jshint ignore:start */
+        <div className="container">
+          <div className="container-box">
+            <header className="pending-header header-box">
+              <ConversationBranding />
+            </header>
+
+            <div id="cameraPreview"></div>
+
+            <div id="messages"></div>
+
+            <p className="standalone-btn-label">
+              {callState}
+            </p>
+
+            <div className="btn-pending-cancel-group btn-group">
+              <div className="flex-padding-1"></div>
+              <button className="btn btn-large btn-cancel"
+                      onClick={this._cancelOutgoingCall} >
+                <span className="standalone-call-btn-text">
+                  {mozL10n.get("initiate_call_cancel_button")}
+                </span>
+              </button>
+              <div className="flex-padding-1"></div>
+            </div>
+          </div>
+
+          <ConversationFooter />
+        </div>
+        /* jshint ignore:end */
+      );
+    }
+  });
+
   /**
    * Conversation launcher view. A ConversationModel is associated and attached
    * as a `model` property.
    */
   var StartConversationView = React.createClass({
     /**
      * Constructor.
      *
@@ -281,17 +351,17 @@ loop.webapp = (function($, _, OT, mozL10
       return (
         /* jshint ignore:start */
         <div className="container">
           <div className="container-box">
 
             <ConversationHeader
               urlCreationDateString={this.state.urlCreationDateString} />
 
-            <p className="standalone-call-btn-label">
+            <p className="standalone-btn-label">
               {mozL10n.get("initiate_call_button_label2")}
             </p>
 
             <div id="messages"></div>
 
             <div className="btn-group">
               <div className="flex-padding-1"></div>
               <div className="standalone-btn-chevron-menu-group">
@@ -347,30 +417,29 @@ loop.webapp = (function($, _, OT, mozL10
    * Webapp Router.
    */
   var WebappRouter = loop.shared.router.BaseConversationRouter.extend({
     routes: {
       "":                    "home",
       "unsupportedDevice":   "unsupportedDevice",
       "unsupportedBrowser":  "unsupportedBrowser",
       "call/expired":        "expired",
+      "call/pending/:token": "pendingConversation",
       "call/ongoing/:token": "loadConversation",
       "call/:token":         "initiate"
     },
 
     initialize: function(options) {
       this.helper = options.helper;
       if (!this.helper) {
         throw new Error("WebappRouter requires a helper object");
       }
 
       // Load default view
       this.loadReactComponent(<HomeView />);
-
-      this.listenTo(this._conversation, "timeout", this._onTimeout);
     },
 
     _onSessionExpired: function() {
       this.navigate("/call/expired", {trigger: true});
     },
 
     /**
      * Starts the set up of a call, obtaining the required information from the
@@ -412,36 +481,35 @@ loop.webapp = (function($, _, OT, mozL10
      * Actually starts the call.
      */
     startCall: function() {
       var loopToken = this._conversation.get("loopToken");
       if (!loopToken) {
         this._notifications.errorL10n("missing_conversation_info");
         this.navigate("home", {trigger: true});
       } else {
-        this._setupWebSocketAndCallView(loopToken);
+        this.navigate("call/pending/" + loopToken, {
+          trigger: true
+        });
       }
     },
 
     /**
      * Used to set up the web socket connection and navigate to the
      * call view if appropriate.
      *
      * @param {string} loopToken The session token to use.
      */
-    _setupWebSocketAndCallView: function(loopToken) {
+    _setupWebSocketAndCallView: function() {
       this._websocket = new loop.CallConnectionWebSocket({
         url: this._conversation.get("progressURL"),
         websocketToken: this._conversation.get("websocketToken"),
         callId: this._conversation.get("callId"),
       });
       this._websocket.promiseConnect().then(function() {
-        this.navigate("call/ongoing/" + loopToken, {
-          trigger: true
-        });
       }.bind(this), function() {
         // XXX Not the ideal response, but bug 1047410 will be replacing
         // this by better "call failed" UI.
         this._notifications.errorL10n("cannot_start_call_session_not_ready");
         return;
       }.bind(this));
 
       this._websocket.on("progress", this._handleWebSocketProgress, this);
@@ -459,57 +527,71 @@ loop.webapp = (function($, _, OT, mozL10
       }
     },
 
     /**
      * Used to receive websocket progress and to determine how to handle
      * it if appropraite.
      */
     _handleWebSocketProgress: function(progressData) {
-      if (progressData.state === "terminated") {
-        // XXX Before adding more states here, the basic protocol messages to the
-        // server need implementing on both the standalone and desktop side.
-        // These are covered by bug 1045643, but also check the dependencies on
-        // bug 1034041.
-        //
-        // Failure to do this will break desktop - standalone call setup. We're
-        // ok to handle reject, as that is a specific message from the destkop via
-        // the server.
-        switch (progressData.reason) {
-          case "reject":
-            this._handleCallRejected();
+      switch(progressData.state) {
+        case "connecting": {
+          this._handleCallConnecting();
+          break;
+        }
+        case "terminated": {
+          // At the moment, we show the same text regardless
+          // of the terminated reason.
+          this._handleCallTerminated(progressData.reason);
+          break;
         }
       }
     },
 
     /**
-     * Handles call rejection.
-     * XXX This should really display the call failed view - bug 1046959
-     * will implement this.
+     * Handles a call moving to the connecting stage.
      */
-    _handleCallRejected: function() {
+    _handleCallConnecting: function() {
+      var loopToken = this._conversation.get("loopToken");
+      if (!loopToken) {
+        this._notifications.errorL10n("missing_conversation_info");
+        return;
+      }
+
+      this.navigate("call/ongoing/" + loopToken, {
+        trigger: true
+      });
+    },
+
+    /**
+     * Handles call rejection.
+     *
+     * @param {String} reason The reason the call was terminated.
+     */
+    _handleCallTerminated: function(reason) {
       this.endCall();
-      this._notifications.errorL10n("call_timeout_notification_text");
+      // For reasons other than cancel, display some notification text.
+      if (reason !== "cancel") {
+        // XXX This should really display the call failed view - bug 1046959
+        // will implement this.
+        this._notifications.errorL10n("call_timeout_notification_text");
+      }
     },
 
     /**
      * @override {loop.shared.router.BaseConversationRouter.endCall}
      */
     endCall: function() {
       var route = "home";
       if (this._conversation.get("loopToken")) {
         route = "call/" + this._conversation.get("loopToken");
       }
       this.navigate(route, {trigger: true});
     },
 
-    _onTimeout: function() {
-      this._notifications.errorL10n("call_timeout_notification_text");
-    },
-
     /**
      * Default entry point.
      */
     home: function() {
       this.loadReactComponent(<HomeView />);
     },
 
     unsupportedDevice: function() {
@@ -544,16 +626,27 @@ loop.webapp = (function($, _, OT, mozL10
         client: this._client
       });
       this._conversation.once("call:outgoing:setup", this.setupOutgoingCall, this);
       this._conversation.once("change:publishedStream", this._checkConnected, this);
       this._conversation.once("change:subscribedStream", this._checkConnected, this);
       this.loadReactComponent(startView);
     },
 
+    pendingConversation: function(loopToken) {
+      if (!this._conversation.isSessionReady()) {
+        // User has loaded this url directly, actually setup the call.
+        return this.navigate("call/" + loopToken, {trigger: true});
+      }
+      this._setupWebSocketAndCallView();
+      this.loadReactComponent(PendingConversationView({
+        websocket: this._websocket
+      }));
+    },
+
     /**
      * Loads conversation establishment view.
      *
      */
     loadConversation: function(loopToken) {
       if (!this._conversation.isSessionReady()) {
         // User has loaded this url directly, actually setup the call.
         return this.navigate("call/" + loopToken, {trigger: true});
@@ -591,18 +684,17 @@ loop.webapp = (function($, _, OT, mozL10
     var client = new loop.StandaloneClient({
       baseServerUrl: baseServerUrl
     });
     var router = new WebappRouter({
       helper: helper,
       notifications: new sharedModels.NotificationCollection(),
       client: client,
       conversation: new sharedModels.ConversationModel({}, {
-        sdk: OT,
-        pendingCallTimeout: loop.config.pendingCallTimeout
+        sdk: OT
       })
     });
 
     Backbone.history.start();
     if (helper.isIOS(navigator.platform)) {
       router.navigate("unsupportedDevice", {trigger: true});
     } else if (!OT.checkSystemRequirements()) {
       router.navigate("unsupportedBrowser", {trigger: true});
@@ -611,16 +703,17 @@ loop.webapp = (function($, _, OT, mozL10
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
   }
 
   return {
     baseServerUrl: baseServerUrl,
     CallUrlExpiredView: CallUrlExpiredView,
+    PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
     WebappHelper: WebappHelper,
     WebappRouter: WebappRouter
--- a/browser/components/loop/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -31,17 +31,17 @@ connection_error_see_console_notificatio
 call_url_unavailable_notification_heading=Oops!
 call_url_unavailable_notification_message2=Sorry, this URL is not available. It may be expired or entered incorrectly.
 promote_firefox_hello_heading=Download {{brandShortname}} to make free audio and video calls!
 get_firefox_button=Get {{brandShortname}}
 initiate_call_button_label2=Ready to start your conversation?
 initiate_audio_video_call_button2=Start
 initiate_audio_video_call_tooltip2=Start a video conversation
 initiate_audio_call_button2=Voice conversation
-reject_incoming_call=Cancel
+initiate_call_cancel_button=Cancel
 legal_text_and_links=By using this product you agree to the {{terms_of_use_url}} and {{privacy_notice_url}}
 terms_of_use_link_text=Terms of use
 privacy_notice_link_text=Privacy notice
 brandShortname=Firefox
 clientShortname=WebRTC!
 ## LOCALIZATION NOTE (call_url_creation_date_label): Example output: (from May 26, 2014)
 call_url_creation_date_label=(from {{call_url_creation_date}})
 call_progress_connecting_description=Connecting…
--- a/browser/components/loop/standalone/server.js
+++ b/browser/components/loop/standalone/server.js
@@ -10,18 +10,17 @@ var loopServerPort = process.env.LOOP_SE
 
 app.get('/content/config.js', function (req, res) {
   "use strict";
 
   res.set('Content-Type', 'text/javascript');
   res.send(
     "var loop = loop || {};" +
     "loop.config = loop.config || {};" +
-    "loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';" +
-    "loop.config.pendingCallTimeout = 20000;"
+    "loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';"
   );
 
 });
 
 // This lets /test/ be mapped to the right place for running tests
 app.use('/', express.static(__dirname + '/../'));
 // This lets /content/ be mapped right for the static contents.
 app.use('/', express.static(__dirname + '/'));
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -48,33 +48,25 @@ describe("loop.shared.models", function(
 
   describe("ConversationModel", function() {
     describe("#initialize", function() {
       it("should require a sdk option", function() {
         expect(function() {
           new sharedModels.ConversationModel({}, {});
         }).to.Throw(Error, /missing required sdk/);
       });
-
-      it("should accept a pendingCallTimeout option", function() {
-        expect(new sharedModels.ConversationModel({}, {
-          sdk: {},
-          pendingCallTimeout: 1000
-        }).pendingCallTimeout).eql(1000);
-      });
     });
 
     describe("constructed", function() {
       var conversation, fakeClient, fakeBaseServerUrl,
           requestCallInfoStub, requestCallsInfoStub;
 
       beforeEach(function() {
         conversation = new sharedModels.ConversationModel({}, {
-          sdk: fakeSDK,
-          pendingCallTimeout: 1000
+          sdk: fakeSDK
         });
         conversation.set("loopToken", "fakeToken");
         fakeBaseServerUrl = "http://fakeBaseServerUrl";
         fakeClient = {
           requestCallInfo: sandbox.stub(),
           requestCallsInfo: sandbox.stub()
         };
         requestCallInfoStub = fakeClient.requestCallInfo;
@@ -116,35 +108,16 @@ describe("loop.shared.models", function(
 
         it("should trigger a `call:outgoing` event", function(done) {
           conversation.once("call:outgoing", function() {
             done();
           });
 
           conversation.outgoing();
         });
-
-        it("should end the session on outgoing call timeout", function() {
-          conversation.outgoing();
-
-          sandbox.clock.tick(1001);
-
-          sinon.assert.calledOnce(conversation.endSession);
-        });
-
-        it("should trigger a `timeout` event on outgoing call timeout",
-          function(done) {
-            conversation.once("timeout", function() {
-              done();
-            });
-
-            conversation.outgoing();
-
-            sandbox.clock.tick(1001);
-          });
       });
 
       describe("#setSessionData", function() {
         it("should update outgoing conversation session information",
            function() {
              conversation.setOutgoingSessionData(fakeSessionData);
 
              expect(conversation.get("sessionId")).eql("sessionId");
@@ -163,21 +136,18 @@ describe("loop.shared.models", function(
              expect(conversation.get("callToken")).eql("callToken");
            });
       });
 
       describe("#startSession", function() {
         var model;
 
         beforeEach(function() {
-          sandbox.stub(sharedModels.ConversationModel.prototype,
-                       "_clearPendingCallTimer");
           model = new sharedModels.ConversationModel(fakeSessionData, {
-            sdk: fakeSDK,
-            pendingCallTimeout: 1000
+            sdk: fakeSDK
           });
           model.startSession();
         });
 
         it("should start a session", function() {
           sinon.assert.calledOnce(fakeSDK.initSession);
         });
 
@@ -276,28 +246,16 @@ describe("loop.shared.models", function(
 
           it("should set the ongoing attribute to false on sessionDisconnected",
             function() {
               fakeSession.trigger("sessionDisconnected", {reason: "ko"});
 
               expect(model.get("ongoing")).eql(false);
             });
 
-          it("should clear a pending timer on session:ended", function() {
-            model.trigger("session:ended");
-
-            sinon.assert.calledOnce(model._clearPendingCallTimer);
-          });
-
-          it("should clear a pending timer on session:error", function() {
-            model.trigger("session:error");
-
-            sinon.assert.calledOnce(model._clearPendingCallTimer);
-          });
-
           describe("connectionDestroyed event received", function() {
             var fakeEvent = {reason: "ko", connection: {connectionId: 42}};
 
             it("should trigger a session:peer-hungup model event",
               function(done) {
                 model.once("session:peer-hungup", function(event) {
                   expect(event.connectionId).eql(42);
                   done();
@@ -336,18 +294,17 @@ describe("loop.shared.models", function(
         });
       });
 
       describe("#endSession", function() {
         var model;
 
         beforeEach(function() {
           model = new sharedModels.ConversationModel(fakeSessionData, {
-            sdk: fakeSDK,
-            pendingCallTimeout: 1000
+            sdk: fakeSDK
           });
           model.startSession();
         });
 
         it("should disconnect current session", function() {
           model.endSession();
 
           sinon.assert.calledOnce(fakeSession.disconnect);
@@ -376,18 +333,17 @@ describe("loop.shared.models", function(
           });
       });
 
       describe("#hasVideoStream", function() {
         var model;
 
         beforeEach(function() {
           model = new sharedModels.ConversationModel(fakeSessionData, {
-            sdk: fakeSDK,
-            pendingCallTimeout: 1000
+            sdk: fakeSDK
           });
           model.startSession();
         });
 
         it("should return true for incoming callType", function() {
           model.set("callType", "audio-video");
 
           expect(model.hasVideoStream("incoming")).to.eql(true);
--- a/browser/components/loop/test/shared/websocket_test.js
+++ b/browser/components/loop/test/shared/websocket_test.js
@@ -171,16 +171,32 @@ describe("loop.CallConnectionWebSocket",
         sinon.assert.calledOnce(dummySocket.send);
         sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
           messageType: "action",
           event: "media-up"
         }));
       });
     });
 
+    describe("#cancel", function() {
+      it("should send a terminate message to the server with a reason of cancel",
+        function() {
+          callWebSocket.promiseConnect();
+
+          callWebSocket.cancel();
+
+          sinon.assert.calledOnce(dummySocket.send);
+          sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
+            messageType: "action",
+            event: "terminate",
+            reason: "cancel"
+          }));
+        });
+    });
+
     describe("Events", function() {
       beforeEach(function() {
         sandbox.stub(callWebSocket, "trigger");
 
         callWebSocket.promiseConnect();
       });
 
       describe("Progress", function() {
@@ -190,19 +206,34 @@ describe("loop.CallConnectionWebSocket",
             state: "terminate",
             reason: "reject"
           };
 
           dummySocket.onmessage({
             data: JSON.stringify(eventData)
           });
 
-          sinon.assert.calledOnce(callWebSocket.trigger);
+          sinon.assert.called(callWebSocket.trigger);
           sinon.assert.calledWithExactly(callWebSocket.trigger, "progress", eventData);
         });
+
+        it("should trigger a progress:<state> event on the callWebSocket", function() {
+          var eventData = {
+            messageType: "progress",
+            state: "terminate",
+            reason: "reject"
+          };
+
+          dummySocket.onmessage({
+            data: JSON.stringify(eventData)
+          });
+
+          sinon.assert.called(callWebSocket.trigger);
+          sinon.assert.calledWithExactly(callWebSocket.trigger, "progress:terminate");
+        });
       });
 
       describe("Error", function() {
         // Handled in constructed -> #promiseConnect:
         //   should reject the promise if the connection errors
 
         it("should trigger an error if state is not completed", function() {
           callWebSocket._clearConnectionFlags();
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -83,16 +83,24 @@ describe("loop.webapp", function() {
         helper: {},
         client: client,
         conversation: conversation,
         notifications: notifications
       });
       sandbox.stub(router, "navigate");
     });
 
+    describe("#initialize", function() {
+      it("should require a conversation option", function() {
+        expect(function() {
+          new loop.webapp.WebappRouter();
+        }).to.Throw(Error, /missing required conversation/);
+      });
+    });
+
     describe("#startCall", function() {
       beforeEach(function() {
         sandbox.stub(router, "_setupWebSocketAndCallView");
       });
 
       it("should navigate back home if session token is missing", function() {
         router.startCall();
 
@@ -104,34 +112,35 @@ describe("loop.webapp", function() {
         sandbox.stub(notifications, "errorL10n");
         router.startCall();
 
         sinon.assert.calledOnce(notifications.errorL10n);
         sinon.assert.calledWithExactly(notifications.errorL10n,
                                        "missing_conversation_info");
       });
 
-      it("should setup the websocket if session token is available", function() {
-        conversation.set("loopToken", "fake");
+      it("should navigate to the pending view if session token is available",
+        function() {
+          conversation.set("loopToken", "fake");
 
-        router.startCall();
+          router.startCall();
 
-        sinon.assert.calledOnce(router._setupWebSocketAndCallView);
-        sinon.assert.calledWithExactly(router._setupWebSocketAndCallView, "fake");
-      });
+          sinon.assert.calledOnce(router.navigate);
+          sinon.assert.calledWithMatch(router.navigate, "call/pending/fake");
+        });
     });
 
     describe("#_setupWebSocketAndCallView", function() {
       beforeEach(function() {
         conversation.setOutgoingSessionData({
           sessionId:      "sessionId",
           sessionToken:   "sessionToken",
           apiKey:         "apiKey",
           callId:         "Hello",
-          progressURL:    "http://progress.example.com",
+          progressURL:    "http://invalid/url",
           websocketToken: 123
         });
       });
 
       describe("Websocket connection successful", function() {
         var promise;
 
         beforeEach(function() {
@@ -149,33 +158,23 @@ describe("loop.webapp", function() {
 
         it("should create a CallConnectionWebSocket", function(done) {
           router._setupWebSocketAndCallView("fake");
 
           promise.then(function () {
             sinon.assert.calledOnce(loop.CallConnectionWebSocket);
             sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, {
               callId: "Hello",
-              url: "http://progress.example.com",
+              url: "http://invalid/url",
               // The websocket token is converted to a hex string.
               websocketToken: "7b"
             });
             done();
           });
         });
-
-        it("should navigate to call/ongoing/:token", function(done) {
-          router._setupWebSocketAndCallView("fake");
-
-          promise.then(function () {
-            sinon.assert.calledOnce(router.navigate);
-            sinon.assert.calledWithMatch(router.navigate, "call/ongoing/fake");
-            done();
-          });
-        });
       });
 
       describe("Websocket connection failed", function() {
         var promise;
 
         beforeEach(function() {
           sandbox.stub(loop, "CallConnectionWebSocket").returns({
             promiseConnect: function() {
@@ -221,38 +220,61 @@ describe("loop.webapp", function() {
 
           router._setupWebSocketAndCallView();
         });
 
         describe("Progress", function() {
           describe("state: terminate, reason: reject", function() {
             beforeEach(function() {
               sandbox.stub(router, "endCall");
+              sandbox.stub(notifications, "errorL10n");
             });
 
             it("should end the call", function() {
               router._websocket.trigger("progress", {
                 state: "terminated",
                 reason: "reject"
               });
 
               sinon.assert.calledOnce(router.endCall);
             });
 
-            it("should display an error message", function() {
-              sandbox.stub(notifications, "errorL10n");
+            it("should display an error message if the reason is not 'cancel'",
+              function() {
+                router._websocket.trigger("progress", {
+                  state: "terminated",
+                  reason: "reject"
+                });
+
+                sinon.assert.calledOnce(notifications.errorL10n);
+                sinon.assert.calledWithExactly(notifications.errorL10n,
+                  "call_timeout_notification_text");
+              });
+
+            it("should not display an error message if the reason is 'cancel'",
+              function() {
+                router._websocket.trigger("progress", {
+                  state: "terminated",
+                  reason: "cancel"
+                });
+
+                sinon.assert.notCalled(notifications.errorL10n);
+              });
+          });
+
+          describe("state: connecting", function() {
+            it("should navigate to the ongoing view", function() {
+              conversation.set({"loopToken": "fakeToken"});
 
               router._websocket.trigger("progress", {
-                state: "terminated",
-                reason: "reject"
+                state: "connecting"
               });
 
-              sinon.assert.calledOnce(router._notifications.errorL10n);
-              sinon.assert.calledWithExactly(router._notifications.errorL10n,
-                "call_timeout_notification_text");
+              sinon.assert.calledOnce(router.navigate);
+              sinon.assert.calledWithMatch(router.navigate, "call/ongoing/fake");
             });
           });
         });
       });
     });
 
     describe("#endCall", function() {
       it("should navigate to home if session token is unset", function() {
@@ -329,16 +351,48 @@ describe("loop.webapp", function() {
           conversation.set("ongoing", true);
 
           router.initiate("fakeToken");
 
           sinon.assert.calledOnce(conversation.endSession);
         });
       });
 
+      describe("#pendingConversation", function() {
+        beforeEach(function() {
+          sandbox.stub(router, "_setupWebSocketAndCallView");
+          conversation.setOutgoingSessionData({
+            sessionId:      "sessionId",
+            sessionToken:   "sessionToken",
+            apiKey:         "apiKey",
+            callId:         "Hello",
+            progressURL:    "http://progress.example.com",
+            websocketToken: 123
+          });
+        });
+
+        it("should setup the websocket", function() {
+          router.pendingConversation();
+
+          sinon.assert.calledOnce(router._setupWebSocketAndCallView);
+          sinon.assert.calledWithExactly(router._setupWebSocketAndCallView);
+        });
+
+        it("should load the PendingConversationView", function() {
+          router.pendingConversation();
+
+          sinon.assert.calledOnce(router.loadReactComponent);
+          sinon.assert.calledWith(router.loadReactComponent,
+            sinon.match(function(value) {
+              return React.addons.TestUtils.isDescriptorOfType(
+                value, loop.webapp.PendingConversationView);
+            }));
+        });
+      });
+
       describe("#loadConversation", function() {
         it("should load the ConversationView if session is set", function() {
           conversation.set("sessionId", "fakeSessionId");
 
           router.loadConversation();
 
           sinon.assert.calledOnce(router.loadReactComponent);
           sinon.assert.calledWith(router.loadReactComponent,
@@ -543,25 +597,56 @@ describe("loop.webapp", function() {
                 sinon.assert.calledWithExactly(conversation.outgoing, fakeSessionData);
               });
           });
         });
       });
     });
   });
 
-  describe("StartConversationView", function() {
-    describe("#initialize", function() {
-      it("should require a conversation option", function() {
-        expect(function() {
-          new loop.webapp.WebappRouter();
-        }).to.Throw(Error, /missing required conversation/);
+  describe("PendingConversationView", function() {
+    var view, websocket;
+
+    beforeEach(function() {
+      websocket = new loop.CallConnectionWebSocket({
+        url: "wss://fake/",
+        callId: "callId",
+        websocketToken: "7b"
+      });
+
+      sinon.stub(websocket, "cancel");
+
+      view = React.addons.TestUtils.renderIntoDocument(
+        loop.webapp.PendingConversationView({
+          websocket: websocket
+        })
+      );
+    });
+
+    describe("#_cancelOutgoingCall", function() {
+      it("should inform the websocket to cancel the setup", function() {
+        var button = view.getDOMNode().querySelector(".btn-cancel");
+        React.addons.TestUtils.Simulate.click(button);
+
+        sinon.assert.calledOnce(websocket.cancel);
       });
     });
 
+    describe("Events", function() {
+      describe("progress:alerting", function() {
+        it("should update the callstate to ringing", function () {
+          websocket.trigger("progress:alerting");
+
+          expect(view.state.callState).to.be.equal("ringing");
+        });
+      });
+    });
+  });
+
+  describe("StartConversationView", function() {
     describe("#initiate", function() {
       var conversation, setupOutgoingCall, view, fakeSubmitEvent,
           requestCallUrlInfo;
 
       beforeEach(function() {
         conversation = new sharedModels.ConversationModel({}, {
           sdk: {},
           pendingCallTimeout: 1000
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -16,16 +16,17 @@
   // 1.2. Conversation Window
   var IncomingCallView = loop.conversation.IncomingCallView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
   var CallUrlExpiredView    = loop.webapp.CallUrlExpiredView;
+  var PendingConversationView = loop.webapp.PendingConversationView;
   var StartConversationView = loop.webapp.StartConversationView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var ConversationView = loop.shared.views.ConversationView;
   var FeedbackView = loop.shared.views.FeedbackView;
 
   // Local helpers
@@ -190,16 +191,29 @@
                 ConversationToolbar({video: {enabled: true}, 
                                      audio: {enabled: false}, 
                                      hangup: noop, 
                                      publishStream: noop})
               )
             )
           ), 
 
+          Section({name: "PendingConversationView"}, 
+            Example({summary: "Pending conversation view (connecting)", dashed: "true"}, 
+              React.DOM.div({className: "standalone"}, 
+                PendingConversationView(null)
+              )
+            ), 
+            Example({summary: "Pending conversation view (ringing)", dashed: "true"}, 
+              React.DOM.div({className: "standalone"}, 
+                PendingConversationView({callState: "ringing"})
+              )
+            )
+          ), 
+
           Section({name: "StartConversationView"}, 
             Example({summary: "Start conversation view", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
                 StartConversationView({model: mockConversationModel, 
                                        client: mockClient, 
                                        notifications: notifications, 
                                        showCallOptionsMenu: true})
               )
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -16,16 +16,17 @@
   // 1.2. Conversation Window
   var IncomingCallView = loop.conversation.IncomingCallView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
   var CallUrlExpiredView    = loop.webapp.CallUrlExpiredView;
+  var PendingConversationView = loop.webapp.PendingConversationView;
   var StartConversationView = loop.webapp.StartConversationView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var ConversationView = loop.shared.views.ConversationView;
   var FeedbackView = loop.shared.views.FeedbackView;
 
   // Local helpers
@@ -190,16 +191,29 @@
                 <ConversationToolbar video={{enabled: true}}
                                      audio={{enabled: false}}
                                      hangup={noop}
                                      publishStream={noop} />
               </Example>
             </div>
           </Section>
 
+          <Section name="PendingConversationView">
+            <Example summary="Pending conversation view (connecting)" dashed="true">
+              <div className="standalone">
+                <PendingConversationView />
+              </div>
+            </Example>
+            <Example summary="Pending conversation view (ringing)" dashed="true">
+              <div className="standalone">
+                <PendingConversationView callState="ringing"/>
+              </div>
+            </Example>
+          </Section>
+
           <Section name="StartConversationView">
             <Example summary="Start conversation view" dashed="true">
               <div className="standalone">
                 <StartConversationView model={mockConversationModel}
                                        client={mockClient}
                                        notifications={notifications}
                                        showCallOptionsMenu={true} />
               </div>