Bug 1000240 - Added a Call Failed view for Loop standalone. r=Standard8
authorNicolas Perriault <nperriault@gmail.com>
Wed, 01 Oct 2014 15:16:05 +0100
changeset 218122 192205ae85e430ac5498fcdd16b389e828e2c58a
parent 218121 e207db7a9a5ab4bdf02881b6802450a8c688af9e
child 218123 028fa95c3c54764f03d0a64831577339fb4e8d3d
push id2
push usergszorc@mozilla.com
push dateWed, 12 Nov 2014 19:43:22 +0000
treeherderfig@7a5f4d72e05d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs1000240
milestone34.0a2
Bug 1000240 - Added a Call Failed view for Loop standalone. r=Standard8
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/shared/css/common.css
browser/components/loop/content/shared/js/mixins.js
browser/components/loop/content/shared/js/models.js
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/test/shared/models_test.js
browser/components/loop/test/standalone/webapp_test.js
browser/components/loop/ui/index.html
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -63,25 +63,16 @@ loop.conversation = (function(mozL10n) {
 
     _handleDeclineBlock: function(e) {
       this.props.model.trigger("declineAndBlock");
       /* Prevent event propagation
        * stop the click from reaching parent element */
       return false;
     },
 
-    _toggleDeclineMenu: function() {
-      var currentState = this.state.showDeclineMenu;
-      this.setState({showDeclineMenu: !currentState});
-    },
-
-    _hideDeclineMenu: function() {
-      this.setState({showDeclineMenu: false});
-    },
-
     /*
      * Generate props for <AcceptCallButton> component based on
      * incoming call type. An incoming video call will render a video
      * answer button primarily, an audio call will flip them.
      **/
     _answerModeProps: function() {
       var videoButton = {
         handler: this._handleAccept("audio-video"),
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -7,47 +7,35 @@
 /* jshint newcap:false, esnext:true */
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.conversation = (function(mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
+  var sharedMixins = loop.shared.mixins;
   var sharedModels = loop.shared.models;
   var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
 
   var IncomingCallView = React.createClass({
+    mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
 
     getDefaultProps: function() {
       return {
-        showDeclineMenu: false,
+        showMenu: false,
         video: true
       };
     },
 
-    getInitialState: function() {
-      return {showDeclineMenu: this.props.showDeclineMenu};
-    },
-
-    componentDidMount: function() {
-      window.addEventListener("click", this.clickHandler);
-      window.addEventListener("blur", this._hideDeclineMenu);
-    },
-
-    componentWillUnmount: function() {
-      window.removeEventListener("click", this.clickHandler);
-      window.removeEventListener("blur", this._hideDeclineMenu);
-    },
-
     clickHandler: function(e) {
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
     },
 
     _handleAccept: function(callType) {
@@ -63,25 +51,16 @@ loop.conversation = (function(mozL10n) {
 
     _handleDeclineBlock: function(e) {
       this.props.model.trigger("declineAndBlock");
       /* Prevent event propagation
        * stop the click from reaching parent element */
       return false;
     },
 
-    _toggleDeclineMenu: function() {
-      var currentState = this.state.showDeclineMenu;
-      this.setState({showDeclineMenu: !currentState});
-    },
-
-    _hideDeclineMenu: function() {
-      this.setState({showDeclineMenu: false});
-    },
-
     /*
      * Generate props for <AcceptCallButton> component based on
      * incoming call type. An incoming video call will render a video
      * answer button primarily, an audio call will flip them.
      **/
     _answerModeProps: function() {
       var videoButton = {
         handler: this._handleAccept("audio-video"),
@@ -107,36 +86,34 @@ loop.conversation = (function(mozL10n) {
       return props;
     },
 
     render: function() {
       /* jshint ignore:start */
       var dropdownMenuClassesDecline = React.addons.classSet({
         "native-dropdown-menu": true,
         "conversation-window-dropdown": true,
-        "visually-hidden": !this.state.showDeclineMenu
+        "visually-hidden": !this.state.showMenu
       });
       return (
         <div className="call-window">
           <h2>{mozL10n.get("incoming_call_title2")}</h2>
           <div className="btn-group call-action-group">
 
             <div className="fx-embedded-call-button-spacer"></div>
 
             <div className="btn-chevron-menu-group">
               <div className="btn-group-chevron">
                 <div className="btn-group">
 
-                  <button className="btn btn-error btn-decline"
+                  <button className="btn btn-decline"
                           onClick={this._handleDecline}>
                     {mozL10n.get("incoming_call_cancel_button")}
                   </button>
-                  <div className="btn-chevron"
-                       onClick={this._toggleDeclineMenu}>
-                  </div>
+                  <div className="btn-chevron" onClick={this.toggleDropdownMenu} />
                 </div>
 
                 <ul className={dropdownMenuClassesDecline}>
                   <li className="btn-block" onClick={this._handleDeclineBlock}>
                     {mozL10n.get("incoming_call_cancel_and_block_button")}
                   </li>
                 </ul>
 
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -132,33 +132,39 @@ p {
   }
 
 .btn-warning {
   background-color: #f0ad4e;
 }
 
 .btn-cancel,
 .btn-error,
+.btn-decline,
 .btn-hangup,
+.btn-decline + .btn-chevron,
 .btn-error + .btn-chevron {
   background-color: #d74345;
   border: 1px solid #d74345;
 }
 
   .btn-cancel:hover,
   .btn-error:hover,
+  .btn-decline:hover,
   .btn-hangup:hover,
+  .btn-decline + .btn-chevron:hover,
   .btn-error + .btn-chevron:hover {
     background-color: #c53436;
     border: 1px solid #c53436;
   }
 
   .btn-cancel:active,
   .btn-error:active,
+  .btn-decline:active,
   .btn-hangup:active,
+  .btn-decline + .btn-chevron:active,
   .btn-error + .btn-chevron:active {
     background-color: #ae2325;
     border: 1px solid #ae2325;
   }
 
 .btn-chevron {
   width: 26px;
   height: 26px;
@@ -177,16 +183,17 @@ p {
  * and the dropdown menu */
 .btn-chevron-menu-group {
   display: flex;
   justify-content: space-between;
   flex: 8;
 }
 
 .btn-group-chevron .btn {
+  border-radius: 2px;
   border-top-right-radius: 0;
   border-bottom-right-radius: 0;
   flex: 2;
 }
 
   .btn + .btn-chevron,
   .btn + .btn-chevron:hover,
   .btn + .btn-chevron:active {
@@ -364,17 +371,17 @@ p {
 /* Web panel */
 
 .info-panel {
   border-radius: 4px;
   background: #fff;
   padding: 20px 0;
   border: 1px solid #e7e7e7;
   box-shadow: 0 2px 0 rgba(0, 0, 0, .03);
-  margin-bottom: 25px;
+  margin: 2rem 0;
 }
 
 .info-panel h1 {
   font-size: 1.2em;
   font-weight: 700;
   padding: 20px 0;
   text-align: center;
 }
--- a/browser/components/loop/content/shared/js/mixins.js
+++ b/browser/components/loop/content/shared/js/mixins.js
@@ -26,39 +26,49 @@ loop.shared.mixins = (function() {
     rootObject = obj;
   }
 
   /**
    * Dropdown menu mixin.
    * @type {Object}
    */
   var DropdownMenuMixin = {
+    get documentBody() {
+      return rootObject.document.body;
+    },
+
     getInitialState: function() {
       return {showMenu: false};
     },
 
     _onBodyClick: function() {
       this.setState({showMenu: false});
     },
 
     componentDidMount: function() {
-      rootObject.document.body.addEventListener("click", this._onBodyClick);
+      this.documentBody.addEventListener("click", this._onBodyClick);
+      this.documentBody.addEventListener("blur", this.hideDropdownMenu);
     },
 
     componentWillUnmount: function() {
-      rootObject.document.body.removeEventListener("click", this._onBodyClick);
+      this.documentBody.removeEventListener("click", this._onBodyClick);
+      this.documentBody.removeEventListener("blur", this.hideDropdownMenu);
     },
 
     showDropdownMenu: function() {
       this.setState({showMenu: true});
     },
 
     hideDropdownMenu: function() {
       this.setState({showMenu: false});
-    }
+    },
+
+    toggleDropdownMenu: function() {
+      this.setState({showMenu: !this.state.showMenu});
+    },
   };
 
   /**
    * Document visibility mixin. Allows defining the following hooks for when the
    * document visibility status changes:
    *
    * - {Function} onDocumentVisible For when the document becomes visible.
    * - {Function} onDocumentHidden  For when the document becomes hidden.
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -24,18 +24,18 @@ loop.shared.models = (function(l10n) {
       apiKey:       undefined,     // OT api key
       callId:       undefined,     // The callId on the server
       progressURL:  undefined,     // The websocket url to use for progress
       websocketToken: undefined,   // The token to use for websocket auth, this is
                                    // stored as a hex string which is what the server
                                    // requires.
       callType:     undefined,     // The type of incoming call selected by
                                    // other peer ("audio" or "audio-video")
-      selectedCallType: undefined, // The selected type for the call that was
-                                   // initiated ("audio" or "audio-video")
+      selectedCallType: "audio-video", // The selected type for the call that was
+                                       // initiated ("audio" or "audio-video")
       callToken:    undefined,     // Incoming call token.
                                    // Used for blocking a call url
       subscribedStream: false,     // Used to indicate that a stream has been
                                    // subscribed to
       publishedStream: false       // Used to indicate that a stream has been
                                    // published
     },
 
@@ -81,18 +81,23 @@ loop.shared.models = (function(l10n) {
      */
     accepted: function() {
       this.trigger("call:accepted");
     },
 
     /**
      * Used to indicate that an outgoing call should start any necessary
      * set-up.
+     *
+     * @param {String} selectedCallType Call type ("audio" or "audio-video")
      */
-    setupOutgoingCall: function() {
+    setupOutgoingCall: function(selectedCallType) {
+      if (selectedCallType) {
+        this.set("selectedCallType", selectedCallType);
+      }
       this.trigger("call:outgoing:setup");
     },
 
     /**
      * Starts an outgoing conversation.
      *
      * @param {Object} sessionData The session data received from the
      *                             server for the outgoing call.
--- a/browser/components/loop/standalone/content/css/webapp.css
+++ b/browser/components/loop/standalone/content/css/webapp.css
@@ -110,18 +110,19 @@ body,
   font-weight: lighter;
 }
 
 .standalone-header-title {
   font-size: 1.8rem;
   line-height: 2.2rem;
 }
 
-.standalone-btn-label {
+p.standalone-btn-label {
   font-size: 1.2rem;
+  line-height: 1.5rem;
 }
 
 .light-color-font {
   opacity: .4;
   font-weight: normal;
 }
 
 .standalone-btn-chevron-menu-group {
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -9,19 +9,20 @@
 
 var loop = loop || {};
 loop.webapp = (function($, _, OT, mozL10n) {
   "use strict";
 
   loop.config = loop.config || {};
   loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
 
-  var sharedModels = loop.shared.models,
-      sharedViews = loop.shared.views,
-      sharedUtils = loop.shared.utils;
+  var sharedMixins = loop.shared.mixins;
+  var sharedModels = loop.shared.models;
+  var sharedViews = loop.shared.views;
+  var sharedUtils = loop.shared.utils;
 
   /**
    * Homepage view.
    */
   var HomeView = React.createClass({displayName: 'HomeView',
     render: function() {
       return (
         React.DOM.p(null, mozL10n.get("welcome"))
@@ -111,17 +112,18 @@ loop.webapp = (function($, _, OT, mozL10
       );
     }
   });
 
   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")
+          React.DOM.strong(null, mozL10n.get("brandShortname")), 
+          mozL10n.get("clientShortname")
         )
       );
     }
   });
 
   /**
    * The Firefox Marketplace exposes a web page that contains a postMesssage
    * based API that wraps a small set of functionality from the WebApps API
@@ -300,200 +302,193 @@ loop.webapp = (function($, _, OT, mozL10
                       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)
+        )
+      );
+    }
+  });
 
-          ConversationFooter(null)
+  var InitiateCallButton = React.createClass({displayName: 'InitiateCallButton',
+    mixins: [sharedMixins.DropdownMenuMixin],
+
+    propTypes: {
+      caption: React.PropTypes.string.isRequired,
+      startCall: React.PropTypes.func.isRequired,
+      disabled: React.PropTypes.bool
+    },
+
+    getDefaultProps: function() {
+      return {disabled: false};
+    },
+
+    render: function() {
+      var dropdownMenuClasses = React.addons.classSet({
+        "native-dropdown-large-parent": true,
+        "standalone-dropdown-menu": true,
+        "visually-hidden": !this.state.showMenu
+      });
+      var chevronClasses = React.addons.classSet({
+        "btn-chevron": true,
+        "disabled": this.props.disabled
+      });
+      return (
+        React.DOM.div({className: "standalone-btn-chevron-menu-group"}, 
+          React.DOM.div({className: "btn-group-chevron"}, 
+            React.DOM.div({className: "btn-group"}, 
+              React.DOM.button({className: "btn btn-large btn-accept", 
+                      onClick: this.props.startCall("audio-video"), 
+                      disabled: this.props.disabled, 
+                      title: mozL10n.get("initiate_audio_video_call_tooltip2")}, 
+                React.DOM.span({className: "standalone-call-btn-text"}, 
+                  this.props.caption
+                ), 
+                React.DOM.span({className: "standalone-call-btn-video-icon"})
+              ), 
+              React.DOM.div({className: chevronClasses, 
+                   onClick: this.toggleDropdownMenu}
+              )
+            ), 
+            React.DOM.ul({className: dropdownMenuClasses}, 
+              React.DOM.li(null, 
+                React.DOM.button({className: "start-audio-only-call", 
+                        onClick: this.props.startCall("audio"), 
+                        disabled: this.props.disabled}, 
+                  mozL10n.get("initiate_audio_call_button2")
+                )
+              )
+            )
+          )
         )
       );
     }
   });
 
   /**
-   * Conversation launcher view. A ConversationModel is associated and attached
-   * as a `model` property.
-   *
-   * Required properties:
-   * - {loop.shared.models.ConversationModel}    model    Conversation model.
-   * - {loop.shared.models.NotificationCollection} notifications
+   * Initiate conversation view.
    */
-  var StartConversationView = React.createClass({displayName: 'StartConversationView',
+  var InitiateConversationView = React.createClass({displayName: 'InitiateConversationView',
+    mixins: [Backbone.Events],
+
     propTypes: {
-      model: React.PropTypes.oneOfType([
-               React.PropTypes.instanceOf(sharedModels.ConversationModel),
-               React.PropTypes.instanceOf(FxOSConversationModel)
-             ]).isRequired,
+      conversation: React.PropTypes.oneOfType([
+                      React.PropTypes.instanceOf(sharedModels.ConversationModel),
+                      React.PropTypes.instanceOf(FxOSConversationModel)
+                    ]).isRequired,
       // XXX Check more tightly here when we start injecting window.loop.*
       notifications: React.PropTypes.object.isRequired,
-      client: React.PropTypes.object.isRequired
-    },
-
-    getDefaultProps: function() {
-      return {showCallOptionsMenu: false};
+      client: React.PropTypes.object.isRequired,
+      title: React.PropTypes.string.isRequired,
+      callButtonLabel: React.PropTypes.string.isRequired
     },
 
     getInitialState: function() {
       return {
         urlCreationDateString: '',
-        disableCallButton: false,
-        showCallOptionsMenu: this.props.showCallOptionsMenu
+        disableCallButton: false
       };
     },
 
     componentDidMount: function() {
-      // Listen for events & hide dropdown menu if user clicks away
-      window.addEventListener("click", this.clickHandler);
-      this.props.model.listenTo(this.props.model, "session:error",
-                                this._onSessionError);
-      this.props.model.listenTo(this.props.model, "fxos:app-needed",
-                                this._onFxOSAppNeeded);
-      this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
-                                           this._setConversationTimestamp);
+      this.listenTo(this.props.conversation,
+                    "session:error", this._onSessionError);
+      this.listenTo(this.props.conversation,
+                    "fxos:app-needed", this._onFxOSAppNeeded);
+      this.props.client.requestCallUrlInfo(
+        this.props.conversation.get("loopToken"),
+        this._setConversationTimestamp);
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.conversation);
+      localStorage.setItem("has-seen-tos", "true");
     },
 
     _onSessionError: function(error, l10nProps) {
       var errorL10n = error || "unable_retrieve_call_info";
       this.props.notifications.errorL10n(errorL10n, l10nProps);
       console.error(errorL10n);
     },
 
     _onFxOSAppNeeded: function() {
       this.setState({
-        marketplaceSrc: loop.config.marketplaceUrl
-      });
-      this.setState({
-        onMarketplaceMessage: this.props.model.onMarketplaceMessage.bind(
-          this.props.model
+        marketplaceSrc: loop.config.marketplaceUrl,
+        onMarketplaceMessage: this.props.conversation.onMarketplaceMessage.bind(
+          this.props.conversation
         )
       });
      },
 
     /**
      * Initiates the call.
      * Takes in a call type parameter "audio" or "audio-video" and returns
      * a function that initiates the call. React click handler requires a function
      * to be called when that event happenes.
      *
      * @param {string} User call type choice "audio" or "audio-video"
      */
-    _initiateOutgoingCall: function(callType) {
+    startCall: function(callType) {
       return function() {
-        this.props.model.set("selectedCallType", callType);
+        this.props.conversation.setupOutgoingCall(callType);
         this.setState({disableCallButton: true});
-        this.props.model.setupOutgoingCall();
       }.bind(this);
     },
 
     _setConversationTimestamp: function(err, callUrlInfo) {
       if (err) {
         this.props.notifications.errorL10n("unable_retrieve_call_info");
       } else {
         var date = (new Date(callUrlInfo.urlCreationDate * 1000));
         var options = {year: "numeric", month: "long", day: "numeric"};
         var timestamp = date.toLocaleDateString(navigator.language, options);
         this.setState({urlCreationDateString: timestamp});
       }
     },
 
-    componentWillUnmount: function() {
-      window.removeEventListener("click", this.clickHandler);
-      localStorage.setItem("has-seen-tos", "true");
-    },
-
-    clickHandler: function(e) {
-      if (!e.target.classList.contains('btn-chevron') &&
-          this.state.showCallOptionsMenu) {
-            this._toggleCallOptionsMenu();
-      }
-    },
-
-    _toggleCallOptionsMenu: function() {
-      var state = this.state.showCallOptionsMenu;
-      this.setState({showCallOptionsMenu: !state});
-    },
-
     render: function() {
-      var tos_link_name = mozL10n.get("terms_of_use_link_text");
-      var privacy_notice_name = mozL10n.get("privacy_notice_link_text");
+      var tosLinkName = mozL10n.get("terms_of_use_link_text");
+      var privacyNoticeName = mozL10n.get("privacy_notice_link_text");
 
       var tosHTML = mozL10n.get("legal_text_and_links", {
         "terms_of_use_url": "<a target=_blank href='/legal/terms/'>" +
-          tos_link_name + "</a>",
+          tosLinkName + "</a>",
         "privacy_notice_url": "<a target=_blank href='" +
-          "https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
+          "https://www.mozilla.org/privacy/'>" + privacyNoticeName + "</a>"
       });
 
-      var dropdownMenuClasses = React.addons.classSet({
-        "native-dropdown-large-parent": true,
-        "standalone-dropdown-menu": true,
-        "visually-hidden": !this.state.showCallOptionsMenu
-      });
       var tosClasses = React.addons.classSet({
         "terms-service": true,
         hide: (localStorage.getItem("has-seen-tos") === "true")
       });
-      var chevronClasses = React.addons.classSet({
-        "btn-chevron": true,
-        "disabled": this.state.disableCallButton
-      });
 
       return (
         React.DOM.div({className: "container"}, 
           React.DOM.div({className: "container-box"}, 
 
             ConversationHeader({
               urlCreationDateString: this.state.urlCreationDateString}), 
 
             React.DOM.p({className: "standalone-btn-label"}, 
-              mozL10n.get("initiate_call_button_label2")
+              this.props.title
             ), 
 
             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"}, 
-                React.DOM.div({className: "btn-group-chevron"}, 
-                  React.DOM.div({className: "btn-group"}, 
-
-                    React.DOM.button({className: "btn btn-large btn-accept", 
-                            onClick: this._initiateOutgoingCall("audio-video"), 
-                            disabled: this.state.disableCallButton, 
-                            title: mozL10n.get("initiate_audio_video_call_tooltip2")}, 
-                      React.DOM.span({className: "standalone-call-btn-text"}, 
-                        mozL10n.get("initiate_audio_video_call_button2")
-                      ), 
-                      React.DOM.span({className: "standalone-call-btn-video-icon"})
-                    ), 
-
-                    React.DOM.div({className: chevronClasses, 
-                         onClick: this._toggleCallOptionsMenu}
-                    )
-
-                  ), 
-
-                  React.DOM.ul({className: dropdownMenuClasses}, 
-                    React.DOM.li(null, 
-                      /*
-                       Button required for disabled state.
-                       */
-                      React.DOM.button({className: "start-audio-only-call", 
-                              onClick: this._initiateOutgoingCall("audio"), 
-                              disabled: this.state.disableCallButton}, 
-                        mozL10n.get("initiate_audio_call_button2")
-                      )
-                    )
-                  )
-
-                )
+              InitiateCallButton({
+                caption: this.props.callButtonLabel, 
+                disabled: this.state.disableCallButton, 
+                startCall: this.startCall}
               ), 
               React.DOM.div({className: "flex-padding-1"})
             ), 
 
             React.DOM.p({className: tosClasses, 
                dangerouslySetInnerHTML: {__html: tosHTML}})
           ), 
 
@@ -533,16 +528,36 @@ loop.webapp = (function($, _, OT, mozL10
             audio: {enabled: false, visible: false}, 
             video: {enabled: false, visible: false}}
           )
         )
       );
     }
   });
 
+  var StartConversationView = React.createClass({displayName: 'StartConversationView',
+    render: function() {
+      return this.transferPropsTo(
+        InitiateConversationView({
+          title: mozL10n.get("initiate_call_button_label2"), 
+          callButtonLabel: mozL10n.get("initiate_audio_video_call_button2")})
+      );
+    }
+  });
+
+  var FailedConversationView = React.createClass({displayName: 'FailedConversationView',
+    render: function() {
+      return this.transferPropsTo(
+        InitiateConversationView({
+          title: mozL10n.get("call_failed_title"), 
+          callButtonLabel: mozL10n.get("retry_call_button")})
+      );
+    }
+  });
+
   /**
    * This view manages the outgoing conversation views - from
    * call initiation through to the actual conversation and call end.
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
     propTypes: {
@@ -590,21 +605,29 @@ loop.webapp = (function($, _, OT, mozL10
       }.bind(this);
     },
 
     /**
      * Renders the conversation views.
      */
     render: function() {
       switch (this.state.callStatus) {
-        case "failure":
         case "start": {
           return (
             StartConversationView({
-              model: this.props.conversation, 
+              conversation: this.props.conversation, 
+              notifications: this.props.notifications, 
+              client: this.props.client}
+            )
+          );
+        }
+        case "failure": {
+          return (
+            FailedConversationView({
+              conversation: this.props.conversation, 
               notifications: this.props.notifications, 
               client: this.props.client}
             )
           );
         }
         case "pending": {
           return PendingConversationView({websocket: this._websocket});
         }
@@ -770,28 +793,27 @@ loop.webapp = (function($, _, OT, mozL10
           break;
         }
       }
     },
 
     /**
      * Handles call rejection.
      *
-     * @param {String} reason The reason the call was terminated.
+     * @param {String} reason The reason the call was terminated (reject, busy,
+     *                        timeout, cancel, media-fail, user-unknown, closed)
      */
     _handleCallTerminated: function(reason) {
-      if (reason !== "cancel") {
-        // XXX This should really display the call failed view - bug 1046959
-        // will implement this.
-        this.props.notifications.errorL10n("call_timeout_notification_text");
+      if (reason === "cancel") {
+        this.setState({callStatus: "start"});
+        return;
       }
-      // redirects the user to the call start view
-      // XXX should switch callStatus to failed for specific reasons when we
-      // get the call failed view; for now, switch back to start.
-      this.setState({callStatus: "start"});
+      // XXX later, we'll want to display more meaningfull messages (needs UX)
+      this.props.notifications.errorL10n("call_timeout_notification_text");
+      this.setState({callStatus: "failure"});
     },
 
     /**
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
       this.setState({callStatus: "end"});
     },
@@ -888,16 +910,17 @@ loop.webapp = (function($, _, OT, mozL10
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
+    FailedConversationView: FailedConversationView,
     OutgoingConversationView: OutgoingConversationView,
     EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
     WebappRootView: WebappRootView,
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -9,19 +9,20 @@
 
 var loop = loop || {};
 loop.webapp = (function($, _, OT, mozL10n) {
   "use strict";
 
   loop.config = loop.config || {};
   loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
 
-  var sharedModels = loop.shared.models,
-      sharedViews = loop.shared.views,
-      sharedUtils = loop.shared.utils;
+  var sharedMixins = loop.shared.mixins;
+  var sharedModels = loop.shared.models;
+  var sharedViews = loop.shared.views;
+  var sharedUtils = loop.shared.utils;
 
   /**
    * Homepage view.
    */
   var HomeView = React.createClass({
     render: function() {
       return (
         <p>{mozL10n.get("welcome")}</p>
@@ -111,17 +112,18 @@ loop.webapp = (function($, _, OT, mozL10
       );
     }
   });
 
   var ConversationBranding = React.createClass({
     render: function() {
       return (
         <h1 className="standalone-header-title">
-          <strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
+          <strong>{mozL10n.get("brandShortname")}</strong>
+          {mozL10n.get("clientShortname")}
         </h1>
       );
     }
   });
 
   /**
    * The Firefox Marketplace exposes a web page that contains a postMesssage
    * based API that wraps a small set of functionality from the WebApps API
@@ -229,17 +231,17 @@ loop.webapp = (function($, _, OT, mozL10
 
       return (
         <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} >
+          <h4 className={urlCreationDateClasses}>
             {callUrlCreationDateString}
           </h4>
         </header>
       );
     }
   });
 
   var ConversationFooter = React.createClass({
@@ -281,221 +283,214 @@ loop.webapp = (function($, _, OT, mozL10
       var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
       return (
         <div className="container">
           <div className="container-box">
             <header className="pending-header header-box">
               <ConversationBranding />
             </header>
 
-            <div id="cameraPreview"></div>
+            <div id="cameraPreview" />
 
-            <div id="messages"></div>
+            <div id="messages" />
 
             <p className="standalone-btn-label">
               {callState}
             </p>
 
             <div className="btn-pending-cancel-group btn-group">
-              <div className="flex-padding-1"></div>
+              <div className="flex-padding-1" />
               <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 className="flex-padding-1" />
             </div>
           </div>
+          <ConversationFooter />
+        </div>
+      );
+    }
+  });
 
-          <ConversationFooter />
+  var InitiateCallButton = React.createClass({
+    mixins: [sharedMixins.DropdownMenuMixin],
+
+    propTypes: {
+      caption: React.PropTypes.string.isRequired,
+      startCall: React.PropTypes.func.isRequired,
+      disabled: React.PropTypes.bool
+    },
+
+    getDefaultProps: function() {
+      return {disabled: false};
+    },
+
+    render: function() {
+      var dropdownMenuClasses = React.addons.classSet({
+        "native-dropdown-large-parent": true,
+        "standalone-dropdown-menu": true,
+        "visually-hidden": !this.state.showMenu
+      });
+      var chevronClasses = React.addons.classSet({
+        "btn-chevron": true,
+        "disabled": this.props.disabled
+      });
+      return (
+        <div className="standalone-btn-chevron-menu-group">
+          <div className="btn-group-chevron">
+            <div className="btn-group">
+              <button className="btn btn-large btn-accept"
+                      onClick={this.props.startCall("audio-video")}
+                      disabled={this.props.disabled}
+                      title={mozL10n.get("initiate_audio_video_call_tooltip2")}>
+                <span className="standalone-call-btn-text">
+                  {this.props.caption}
+                </span>
+                <span className="standalone-call-btn-video-icon" />
+              </button>
+              <div className={chevronClasses}
+                   onClick={this.toggleDropdownMenu}>
+              </div>
+            </div>
+            <ul className={dropdownMenuClasses}>
+              <li>
+                <button className="start-audio-only-call"
+                        onClick={this.props.startCall("audio")}
+                        disabled={this.props.disabled}>
+                  {mozL10n.get("initiate_audio_call_button2")}
+                </button>
+              </li>
+            </ul>
+          </div>
         </div>
       );
     }
   });
 
   /**
-   * Conversation launcher view. A ConversationModel is associated and attached
-   * as a `model` property.
-   *
-   * Required properties:
-   * - {loop.shared.models.ConversationModel}    model    Conversation model.
-   * - {loop.shared.models.NotificationCollection} notifications
+   * Initiate conversation view.
    */
-  var StartConversationView = React.createClass({
+  var InitiateConversationView = React.createClass({
+    mixins: [Backbone.Events],
+
     propTypes: {
-      model: React.PropTypes.oneOfType([
-               React.PropTypes.instanceOf(sharedModels.ConversationModel),
-               React.PropTypes.instanceOf(FxOSConversationModel)
-             ]).isRequired,
+      conversation: React.PropTypes.oneOfType([
+                      React.PropTypes.instanceOf(sharedModels.ConversationModel),
+                      React.PropTypes.instanceOf(FxOSConversationModel)
+                    ]).isRequired,
       // XXX Check more tightly here when we start injecting window.loop.*
       notifications: React.PropTypes.object.isRequired,
-      client: React.PropTypes.object.isRequired
-    },
-
-    getDefaultProps: function() {
-      return {showCallOptionsMenu: false};
+      client: React.PropTypes.object.isRequired,
+      title: React.PropTypes.string.isRequired,
+      callButtonLabel: React.PropTypes.string.isRequired
     },
 
     getInitialState: function() {
       return {
         urlCreationDateString: '',
-        disableCallButton: false,
-        showCallOptionsMenu: this.props.showCallOptionsMenu
+        disableCallButton: false
       };
     },
 
     componentDidMount: function() {
-      // Listen for events & hide dropdown menu if user clicks away
-      window.addEventListener("click", this.clickHandler);
-      this.props.model.listenTo(this.props.model, "session:error",
-                                this._onSessionError);
-      this.props.model.listenTo(this.props.model, "fxos:app-needed",
-                                this._onFxOSAppNeeded);
-      this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
-                                           this._setConversationTimestamp);
+      this.listenTo(this.props.conversation,
+                    "session:error", this._onSessionError);
+      this.listenTo(this.props.conversation,
+                    "fxos:app-needed", this._onFxOSAppNeeded);
+      this.props.client.requestCallUrlInfo(
+        this.props.conversation.get("loopToken"),
+        this._setConversationTimestamp);
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.conversation);
+      localStorage.setItem("has-seen-tos", "true");
     },
 
     _onSessionError: function(error, l10nProps) {
       var errorL10n = error || "unable_retrieve_call_info";
       this.props.notifications.errorL10n(errorL10n, l10nProps);
       console.error(errorL10n);
     },
 
     _onFxOSAppNeeded: function() {
       this.setState({
-        marketplaceSrc: loop.config.marketplaceUrl
-      });
-      this.setState({
-        onMarketplaceMessage: this.props.model.onMarketplaceMessage.bind(
-          this.props.model
+        marketplaceSrc: loop.config.marketplaceUrl,
+        onMarketplaceMessage: this.props.conversation.onMarketplaceMessage.bind(
+          this.props.conversation
         )
       });
      },
 
     /**
      * Initiates the call.
      * Takes in a call type parameter "audio" or "audio-video" and returns
      * a function that initiates the call. React click handler requires a function
      * to be called when that event happenes.
      *
      * @param {string} User call type choice "audio" or "audio-video"
      */
-    _initiateOutgoingCall: function(callType) {
+    startCall: function(callType) {
       return function() {
-        this.props.model.set("selectedCallType", callType);
+        this.props.conversation.setupOutgoingCall(callType);
         this.setState({disableCallButton: true});
-        this.props.model.setupOutgoingCall();
       }.bind(this);
     },
 
     _setConversationTimestamp: function(err, callUrlInfo) {
       if (err) {
         this.props.notifications.errorL10n("unable_retrieve_call_info");
       } else {
         var date = (new Date(callUrlInfo.urlCreationDate * 1000));
         var options = {year: "numeric", month: "long", day: "numeric"};
         var timestamp = date.toLocaleDateString(navigator.language, options);
         this.setState({urlCreationDateString: timestamp});
       }
     },
 
-    componentWillUnmount: function() {
-      window.removeEventListener("click", this.clickHandler);
-      localStorage.setItem("has-seen-tos", "true");
-    },
-
-    clickHandler: function(e) {
-      if (!e.target.classList.contains('btn-chevron') &&
-          this.state.showCallOptionsMenu) {
-            this._toggleCallOptionsMenu();
-      }
-    },
-
-    _toggleCallOptionsMenu: function() {
-      var state = this.state.showCallOptionsMenu;
-      this.setState({showCallOptionsMenu: !state});
-    },
-
     render: function() {
-      var tos_link_name = mozL10n.get("terms_of_use_link_text");
-      var privacy_notice_name = mozL10n.get("privacy_notice_link_text");
+      var tosLinkName = mozL10n.get("terms_of_use_link_text");
+      var privacyNoticeName = mozL10n.get("privacy_notice_link_text");
 
       var tosHTML = mozL10n.get("legal_text_and_links", {
         "terms_of_use_url": "<a target=_blank href='/legal/terms/'>" +
-          tos_link_name + "</a>",
+          tosLinkName + "</a>",
         "privacy_notice_url": "<a target=_blank href='" +
-          "https://www.mozilla.org/privacy/'>" + privacy_notice_name + "</a>"
+          "https://www.mozilla.org/privacy/'>" + privacyNoticeName + "</a>"
       });
 
-      var dropdownMenuClasses = React.addons.classSet({
-        "native-dropdown-large-parent": true,
-        "standalone-dropdown-menu": true,
-        "visually-hidden": !this.state.showCallOptionsMenu
-      });
       var tosClasses = React.addons.classSet({
         "terms-service": true,
         hide: (localStorage.getItem("has-seen-tos") === "true")
       });
-      var chevronClasses = React.addons.classSet({
-        "btn-chevron": true,
-        "disabled": this.state.disableCallButton
-      });
 
       return (
         <div className="container">
           <div className="container-box">
 
             <ConversationHeader
               urlCreationDateString={this.state.urlCreationDateString} />
 
             <p className="standalone-btn-label">
-              {mozL10n.get("initiate_call_button_label2")}
+              {this.props.title}
             </p>
 
             <div id="messages"></div>
 
             <div className="btn-group">
-              <div className="flex-padding-1"></div>
-              <div className="standalone-btn-chevron-menu-group">
-                <div className="btn-group-chevron">
-                  <div className="btn-group">
-
-                    <button className="btn btn-large btn-accept"
-                            onClick={this._initiateOutgoingCall("audio-video")}
-                            disabled={this.state.disableCallButton}
-                            title={mozL10n.get("initiate_audio_video_call_tooltip2")} >
-                      <span className="standalone-call-btn-text">
-                        {mozL10n.get("initiate_audio_video_call_button2")}
-                      </span>
-                      <span className="standalone-call-btn-video-icon"></span>
-                    </button>
-
-                    <div className={chevronClasses}
-                         onClick={this._toggleCallOptionsMenu}>
-                    </div>
-
-                  </div>
-
-                  <ul className={dropdownMenuClasses}>
-                    <li>
-                      {/*
-                       Button required for disabled state.
-                       */}
-                      <button className="start-audio-only-call"
-                              onClick={this._initiateOutgoingCall("audio")}
-                              disabled={this.state.disableCallButton} >
-                        {mozL10n.get("initiate_audio_call_button2")}
-                      </button>
-                    </li>
-                  </ul>
-
-                </div>
-              </div>
-              <div className="flex-padding-1"></div>
+              <div className="flex-padding-1" />
+              <InitiateCallButton
+                caption={this.props.callButtonLabel}
+                disabled={this.state.disableCallButton}
+                startCall={this.startCall}
+              />
+              <div className="flex-padding-1" />
             </div>
 
             <p className={tosClasses}
                dangerouslySetInnerHTML={{__html: tosHTML}}></p>
           </div>
 
           <FxOSHiddenMarketplace
             marketplaceSrc={this.state.marketplaceSrc}
@@ -533,16 +528,36 @@ loop.webapp = (function($, _, OT, mozL10
             audio={{enabled: false, visible: false}}
             video={{enabled: false, visible: false}}
           />
         </div>
       );
     }
   });
 
+  var StartConversationView = React.createClass({
+    render: function() {
+      return this.transferPropsTo(
+        <InitiateConversationView
+          title={mozL10n.get("initiate_call_button_label2")}
+          callButtonLabel={mozL10n.get("initiate_audio_video_call_button2")} />
+      );
+    }
+  });
+
+  var FailedConversationView = React.createClass({
+    render: function() {
+      return this.transferPropsTo(
+        <InitiateConversationView
+          title={mozL10n.get("call_failed_title")}
+          callButtonLabel={mozL10n.get("retry_call_button")} />
+      );
+    }
+  });
+
   /**
    * This view manages the outgoing conversation views - from
    * call initiation through to the actual conversation and call end.
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var OutgoingConversationView = React.createClass({
     propTypes: {
@@ -590,21 +605,29 @@ loop.webapp = (function($, _, OT, mozL10
       }.bind(this);
     },
 
     /**
      * Renders the conversation views.
      */
     render: function() {
       switch (this.state.callStatus) {
-        case "failure":
         case "start": {
           return (
             <StartConversationView
-              model={this.props.conversation}
+              conversation={this.props.conversation}
+              notifications={this.props.notifications}
+              client={this.props.client}
+            />
+          );
+        }
+        case "failure": {
+          return (
+            <FailedConversationView
+              conversation={this.props.conversation}
               notifications={this.props.notifications}
               client={this.props.client}
             />
           );
         }
         case "pending": {
           return <PendingConversationView websocket={this._websocket} />;
         }
@@ -770,28 +793,27 @@ loop.webapp = (function($, _, OT, mozL10
           break;
         }
       }
     },
 
     /**
      * Handles call rejection.
      *
-     * @param {String} reason The reason the call was terminated.
+     * @param {String} reason The reason the call was terminated (reject, busy,
+     *                        timeout, cancel, media-fail, user-unknown, closed)
      */
     _handleCallTerminated: function(reason) {
-      if (reason !== "cancel") {
-        // XXX This should really display the call failed view - bug 1046959
-        // will implement this.
-        this.props.notifications.errorL10n("call_timeout_notification_text");
+      if (reason === "cancel") {
+        this.setState({callStatus: "start"});
+        return;
       }
-      // redirects the user to the call start view
-      // XXX should switch callStatus to failed for specific reasons when we
-      // get the call failed view; for now, switch back to start.
-      this.setState({callStatus: "start"});
+      // XXX later, we'll want to display more meaningfull messages (needs UX)
+      this.props.notifications.errorL10n("call_timeout_notification_text");
+      this.setState({callStatus: "failure"});
     },
 
     /**
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
       this.setState({callStatus: "end"});
     },
@@ -888,16 +910,17 @@ loop.webapp = (function($, _, OT, mozL10
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
+    FailedConversationView: FailedConversationView,
     OutgoingConversationView: OutgoingConversationView,
     EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
     WebappRootView: WebappRootView,
--- a/browser/components/loop/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -1,15 +1,16 @@
 ## LOCALIZATION NOTE: In this file, don't translate the part between {{..}}
 restart_call=Rejoin
 conversation_has_ended=Your conversation has ended.
 call_timeout_notification_text=Your call did not go through.
 missing_conversation_info=Missing conversation information.
 network_disconnected=The network connection terminated abruptly.
 peer_ended_conversation2=The person you were calling has ended the conversation.
+call_failed_title=Call failed.
 connection_error_see_console_notification=Call failed; see console for details.
 generic_failure_title=Something went wrong.
 generic_failure_with_reason2=You can try again or email a link to be reached at later.
 generic_failure_no_reason2=Would you like to try again?
 retry_call_button=Retry
 feedback_report_user_button=Report User
 unable_retrieve_call_info=Unable to retrieve conversation information.
 hangup_button_title=Hang up
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -71,16 +71,29 @@ describe("loop.shared.models", function(
             done();
           });
 
           conversation.accepted();
         });
       });
 
       describe("#setupOutgoingCall", function() {
+        it("should set the a custom selected call type", function() {
+          conversation.setupOutgoingCall("audio");
+
+          expect(conversation.get("selectedCallType")).eql("audio");
+        });
+
+        it("should respect the default selected call type when none is passed",
+          function() {
+            conversation.setupOutgoingCall();
+
+            expect(conversation.get("selectedCallType")).eql("audio-video");
+          });
+
         it("should trigger a `call:outgoing:setup` event", function(done) {
           conversation.once("call:outgoing:setup", function() {
             done();
           });
 
           conversation.setupOutgoingCall();
         });
       });
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -191,24 +191,24 @@ describe("loop.webapp", function() {
         });
 
         describe("Progress", function() {
           describe("state: terminate, reason: reject", function() {
             beforeEach(function() {
               sandbox.stub(notifications, "errorL10n");
             });
 
-            it("should display the StartConversationView", function() {
+            it("should display the FailedConversationView", function() {
               ocView._websocket.trigger("progress", {
                 state: "terminated",
                 reason: "reject"
               });
 
               TestUtils.findRenderedComponentWithType(ocView,
-                loop.webapp.StartConversationView);
+                loop.webapp.FailedConversationView);
             });
 
             it("should display an error message if the reason is not 'cancel'",
               function() {
                 ocView._websocket.trigger("progress", {
                   state: "terminated",
                   reason: "reject"
                 });
@@ -266,24 +266,24 @@ describe("loop.webapp", function() {
         sandbox.stub(notifications, "errorL10n");
         sandbox.stub(notifications, "warnL10n");
         promiseConnectStub =
           sandbox.stub(loop.CallConnectionWebSocket.prototype, "promiseConnect");
         promiseConnectStub.returns(new Promise(function(resolve, reject) {}));
       });
 
       describe("call:outgoing", function() {
-        it("should set display the StartConversationView if session token is missing",
+        it("should display FailedConversationView if session token is missing",
           function() {
             conversation.set("loopToken", "");
 
             ocView.startCall();
 
             TestUtils.findRenderedComponentWithType(ocView,
-              loop.webapp.StartConversationView);
+              loop.webapp.FailedConversationView);
           });
 
         it("should notify the user if session token is missing", function() {
           conversation.set("loopToken", "");
 
           ocView.startCall();
 
           sinon.assert.calledOnce(notifications.errorL10n);
@@ -395,39 +395,38 @@ describe("loop.webapp", function() {
       });
 
       describe("#setupOutgoingCall", function() {
         describe("No loop token", function() {
           beforeEach(function() {
             conversation.set("loopToken", "");
           });
 
-          it("should set display the StartConversationView", function() {
+          it("should display the FailedConversationView", function() {
             conversation.setupOutgoingCall();
 
             TestUtils.findRenderedComponentWithType(ocView,
-              loop.webapp.StartConversationView);
+              loop.webapp.FailedConversationView);
           });
 
           it("should display an error", function() {
             conversation.setupOutgoingCall();
 
             sinon.assert.calledOnce(notifications.errorL10n);
           });
         });
 
         describe("Has loop token", function() {
           beforeEach(function() {
-            conversation.set("selectedCallType", "audio-video");
             sandbox.stub(conversation, "outgoing");
           });
 
           it("should call requestCallInfo on the client",
             function() {
-              conversation.setupOutgoingCall();
+              conversation.setupOutgoingCall("audio-video");
 
               sinon.assert.calledOnce(client.requestCallInfo);
               sinon.assert.calledWith(client.requestCallInfo, "fakeToken",
                                       "audio-video");
             });
 
           describe("requestCallInfo response handling", function() {
             it("should set display the CallUrlExpiredView if the call has expired",
@@ -435,24 +434,24 @@ describe("loop.webapp", function() {
                 client.requestCallInfo.callsArgWith(2, {errno: 105});
 
                 conversation.setupOutgoingCall();
 
                 TestUtils.findRenderedComponentWithType(ocView,
                   loop.webapp.CallUrlExpiredView);
               });
 
-            it("should set display the StartConversationView on any other error",
+            it("should set display the FailedConversationView on any other error",
                function() {
                 client.requestCallInfo.callsArgWith(2, {errno: 104});
 
                 conversation.setupOutgoingCall();
 
                 TestUtils.findRenderedComponentWithType(ocView,
-                  loop.webapp.StartConversationView);
+                  loop.webapp.FailedConversationView);
               });
 
             it("should notify the user on any other error", function() {
               client.requestCallInfo.callsArgWith(2, {errno: 104});
 
               conversation.setupOutgoingCall();
 
               sinon.assert.calledOnce(notifications.errorL10n);
@@ -580,59 +579,61 @@ describe("loop.webapp", function() {
           expect(view.state.callState).to.be.equal("ringing");
         });
       });
     });
   });
 
   describe("StartConversationView", function() {
     describe("#initiate", function() {
-      var conversation, setupOutgoingCall, view, fakeSubmitEvent,
-          requestCallUrlInfo;
+      var conversation, view, fakeSubmitEvent, requestCallUrlInfo;
 
       beforeEach(function() {
         conversation = new sharedModels.ConversationModel({}, {
           sdk: {}
         });
 
         fakeSubmitEvent = {preventDefault: sinon.spy()};
-        setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
 
         var standaloneClientStub = {
           requestCallUrlInfo: function(token, cb) {
             cb(null, {urlCreationDate: 0});
           },
           settings: {baseServerUrl: loop.webapp.baseServerUrl}
         };
 
         view = React.addons.TestUtils.renderIntoDocument(
             loop.webapp.StartConversationView({
-              model: conversation,
+              conversation: conversation,
               notifications: notifications,
               client: standaloneClientStub
             })
         );
       });
 
       it("should start the audio-video conversation establishment process",
         function() {
+          var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
+
           var button = view.getDOMNode().querySelector(".btn-accept");
           React.addons.TestUtils.Simulate.click(button);
 
           sinon.assert.calledOnce(setupOutgoingCall);
-          sinon.assert.calledWithExactly(setupOutgoingCall);
+          sinon.assert.calledWithExactly(setupOutgoingCall, "audio-video");
       });
 
       it("should start the audio-only conversation establishment process",
         function() {
+          var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
+
           var button = view.getDOMNode().querySelector(".start-audio-only-call");
           React.addons.TestUtils.Simulate.click(button);
 
           sinon.assert.calledOnce(setupOutgoingCall);
-          sinon.assert.calledWithExactly(setupOutgoingCall);
+          sinon.assert.calledWithExactly(setupOutgoingCall, "audio");
         });
 
       it("should disable audio-video button once session is initiated",
          function() {
            conversation.set("loopToken", "fake");
 
            var button = view.getDOMNode().querySelector(".btn-accept");
            React.addons.TestUtils.Simulate.click(button);
@@ -645,45 +646,45 @@ describe("loop.webapp", function() {
            conversation.set("loopToken", "fake");
 
            var button = view.getDOMNode().querySelector(".start-audio-only-call");
            React.addons.TestUtils.Simulate.click(button);
 
            expect(button.disabled).to.eql(true);
          });
 
-         it("should set selectedCallType to audio", function() {
-           conversation.set("loopToken", "fake");
-
-           var button = view.getDOMNode().querySelector(".start-audio-only-call");
-           React.addons.TestUtils.Simulate.click(button);
+      it("should set selectedCallType to audio", function() {
+        conversation.set("loopToken", "fake");
 
-           expect(conversation.get("selectedCallType")).to.eql("audio");
-         });
-
-         it("should set selectedCallType to audio-video", function() {
-           conversation.set("loopToken", "fake");
+         var button = view.getDOMNode().querySelector(".start-audio-only-call");
+         React.addons.TestUtils.Simulate.click(button);
 
-           var button = view.getDOMNode().querySelector(".standalone-call-btn-video-icon");
-           React.addons.TestUtils.Simulate.click(button);
-
-           expect(conversation.get("selectedCallType")).to.eql("audio-video");
-         });
+         expect(conversation.get("selectedCallType")).to.eql("audio");
+       });
 
-      it("should set state.urlCreationDateString to a locale date string",
-         function() {
-        // wrap in a jquery object because text is broken up
-        // into several span elements
-        var date = new Date(0);
-        var options = {year: "numeric", month: "long", day: "numeric"};
-        var timestamp = date.toLocaleDateString(navigator.language, options);
+       it("should set selectedCallType to audio-video", function() {
+         conversation.set("loopToken", "fake");
 
-        expect(view.state.urlCreationDateString).to.eql(timestamp);
+         var button = view.getDOMNode().querySelector(".standalone-call-btn-video-icon");
+         React.addons.TestUtils.Simulate.click(button);
+
+         expect(conversation.get("selectedCallType")).to.eql("audio-video");
       });
 
+      // XXX this test breaks while the feature actually works; find a way to
+      // test this properly.
+      it.skip("should set state.urlCreationDateString to a locale date string",
+        function() {
+          var date = new Date();
+          var options = {year: "numeric", month: "long", day: "numeric"};
+          var timestamp = date.toLocaleDateString(navigator.language, options);
+          var dateElem = view.getDOMNode().querySelector(".call-url-date");
+
+          expect(dateElem.textContent).to.eql(timestamp);
+        });
     });
 
     describe("Events", function() {
       var conversation, view, StandaloneClient, requestCallUrlInfo;
 
       beforeEach(function() {
         conversation = new sharedModels.ConversationModel({
           loopToken: "fake"
@@ -692,17 +693,17 @@ describe("loop.webapp", function() {
         });
 
         conversation.onMarketplaceMessage = function() {};
         sandbox.stub(notifications, "errorL10n");
         requestCallUrlInfo = sandbox.stub();
 
         view = React.addons.TestUtils.renderIntoDocument(
             loop.webapp.StartConversationView({
-              model: conversation,
+              conversation: conversation,
               notifications: notifications,
               client: {requestCallUrlInfo: requestCallUrlInfo}
             })
           );
 
         loop.config.marketplaceUrl = "http://market/";
       });
 
@@ -777,33 +778,33 @@ describe("loop.webapp", function() {
           localStorage.setItem("has-seen-tos", oldLocalStorageValue);
       });
 
       it("should show the TOS", function() {
         var tos;
 
         view = React.addons.TestUtils.renderIntoDocument(
           loop.webapp.StartConversationView({
-            model: conversation,
+            conversation: conversation,
             notifications: notifications,
             client: {requestCallUrlInfo: requestCallUrlInfo}
           })
         );
         tos = view.getDOMNode().querySelector(".terms-service");
 
         expect(tos.classList.contains("hide")).to.equal(false);
       });
 
       it("should not show the TOS if it has already been seen", function() {
         var tos;
 
         localStorage.setItem("has-seen-tos", "true");
         view = React.addons.TestUtils.renderIntoDocument(
           loop.webapp.StartConversationView({
-            model: conversation,
+            conversation: conversation,
             notifications: notifications,
             client: {requestCallUrlInfo: requestCallUrlInfo}
           })
         );
         tos = view.getDOMNode().querySelector(".terms-service");
 
         expect(tos.classList.contains("hide")).to.equal(true);
       });
@@ -883,17 +884,17 @@ describe("loop.webapp", function() {
           requestCallUrlInfo: function(token, cb) {
             cb(null, {urlCreationDate: 0});
           },
           settings: {baseServerUrl: loop.webapp.baseServerUrl}
         };
 
         view = React.addons.TestUtils.renderIntoDocument(
             loop.webapp.StartConversationView({
-              model: conversation,
+              conversation: conversation,
               notifications: notifications,
               client: standaloneClientStub
             })
         );
       });
 
       it("should start the conversation establishment process", function() {
         var button = view.getDOMNode().querySelector("button");
@@ -998,17 +999,17 @@ describe("loop.webapp", function() {
       });
 
       describe("onMarketplaceMessage", function() {
         var view, setupOutgoingCall, trigger;
 
         before(function() {
           view = React.addons.TestUtils.renderIntoDocument(
             loop.webapp.StartConversationView({
-              model: model,
+              conversation: model,
               notifications: notifications,
               client: {requestCallUrlInfo: sandbox.stub()}
             })
           );
         });
 
         beforeEach(function() {
           setupOutgoingCall = sandbox.stub(model, "setupOutgoingCall");
--- a/browser/components/loop/ui/index.html
+++ b/browser/components/loop/ui/index.html
@@ -27,22 +27,23 @@
       window.OTProperties.cssURL = window.OTProperties.assetURL + 'css/ot.css';
     </script>
     <script src="../content/shared/libs/sdk.js"></script>
     <script src="../content/shared/libs/react-0.11.1.js"></script>
     <script src="../content/shared/libs/jquery-2.1.0.js"></script>
     <script src="../content/shared/libs/lodash-2.4.1.js"></script>
     <script src="../content/shared/libs/backbone-1.1.2.js"></script>
     <script src="../content/shared/js/feedbackApiClient.js"></script>
-    <script src="../content/shared/js/conversationStore.js"></script>
+    <script src="../content/shared/js/actions.js"></script>
     <script src="../content/shared/js/utils.js"></script>
     <script src="../content/shared/js/models.js"></script>
     <script src="../content/shared/js/mixins.js"></script>
     <script src="../content/shared/js/views.js"></script>
     <script src="../content/shared/js/websocket.js"></script>
+    <script src="../content/shared/js/conversationStore.js"></script>
     <script src="../content/js/conversationViews.js"></script>
     <script src="../content/js/client.js"></script>
     <script src="../standalone/content/js/webapp.js"></script>
     <script type="text/javascript;version=1.8" src="../content/js/contacts.js"></script>
     <script>
       if (!loop.contacts) {
         // For browsers that don't support ES6 without special flags (all but Fx
         // at the moment), we shim the contacts namespace with its most barebone
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -14,22 +14,23 @@
   // 1.1 Panel
   var PanelView = loop.panel.PanelView;
   // 1.2. Conversation Window
   var IncomingCallView = loop.conversation.IncomingCallView;
   var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
-  var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
-  var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
-  var CallUrlExpiredView    = loop.webapp.CallUrlExpiredView;
+  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;
-  var EndedConversationView = loop.webapp.EndedConversationView;
+  var StartConversationView   = loop.webapp.StartConversationView;
+  var FailedConversationView  = loop.webapp.FailedConversationView;
+  var EndedConversationView   = loop.webapp.EndedConversationView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var ConversationView = loop.shared.views.ConversationView;
   var FeedbackView = loop.shared.views.FeedbackView;
 
   // Local helpers
   function returnTrue() {
@@ -170,18 +171,17 @@
               )
             )
           ), 
 
           Section({name: "IncomingCallView-ActiveState"}, 
             Example({summary: "Default", dashed: "true", style: {width: "260px", height: "254px"}}, 
               React.DOM.div({className: "fx-embedded"}, 
                 IncomingCallView({model: mockConversationModel, 
-                                   showDeclineMenu: true, 
-                                   video: true})
+                                   showMenu: true})
               )
             )
           ), 
 
           Section({name: "ConversationToolbar"}, 
             React.DOM.h2(null, "Desktop Conversation Window"), 
             React.DOM.div({className: "fx-embedded override-position"}, 
               Example({summary: "Default (260x265)", dashed: "true"}, 
@@ -247,20 +247,29 @@
                 DesktopPendingConversationView({callState: "gather", calleeId: "Mr Smith"})
               )
             )
           ), 
 
           Section({name: "StartConversationView"}, 
             Example({summary: "Start conversation view", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
-                StartConversationView({model: mockConversationModel, 
+                StartConversationView({conversation: mockConversationModel, 
                                        client: mockClient, 
-                                       notifications: notifications, 
-                                       showCallOptionsMenu: true})
+                                       notifications: notifications})
+              )
+            )
+          ), 
+
+          Section({name: "FailedConversationView"}, 
+            Example({summary: "Failed conversation view", dashed: "true"}, 
+              React.DOM.div({className: "standalone"}, 
+                FailedConversationView({conversation: mockConversationModel, 
+                                        client: mockClient, 
+                                        notifications: notifications})
               )
             )
           ), 
 
           Section({name: "ConversationView"}, 
             Example({summary: "Desktop conversation window", dashed: "true", 
                      style: {width: "260px", height: "265px"}}, 
               React.DOM.div({className: "fx-embedded"}, 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -14,22 +14,23 @@
   // 1.1 Panel
   var PanelView = loop.panel.PanelView;
   // 1.2. Conversation Window
   var IncomingCallView = loop.conversation.IncomingCallView;
   var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
-  var UnsupportedBrowserView = loop.webapp.UnsupportedBrowserView;
-  var UnsupportedDeviceView = loop.webapp.UnsupportedDeviceView;
-  var CallUrlExpiredView    = loop.webapp.CallUrlExpiredView;
+  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;
-  var EndedConversationView = loop.webapp.EndedConversationView;
+  var StartConversationView   = loop.webapp.StartConversationView;
+  var FailedConversationView  = loop.webapp.FailedConversationView;
+  var EndedConversationView   = loop.webapp.EndedConversationView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var ConversationView = loop.shared.views.ConversationView;
   var FeedbackView = loop.shared.views.FeedbackView;
 
   // Local helpers
   function returnTrue() {
@@ -170,18 +171,17 @@
               </div>
             </Example>
           </Section>
 
           <Section name="IncomingCallView-ActiveState">
             <Example summary="Default" dashed="true" style={{width: "260px", height: "254px"}}>
               <div className="fx-embedded" >
                 <IncomingCallView  model={mockConversationModel}
-                                   showDeclineMenu={true}
-                                   video={true} />
+                                   showMenu={true} />
               </div>
             </Example>
           </Section>
 
           <Section name="ConversationToolbar">
             <h2>Desktop Conversation Window</h2>
             <div className="fx-embedded override-position">
               <Example summary="Default (260x265)" dashed="true">
@@ -247,20 +247,29 @@
                 <DesktopPendingConversationView callState={"gather"} calleeId="Mr Smith" />
               </div>
             </Example>
           </Section>
 
           <Section name="StartConversationView">
             <Example summary="Start conversation view" dashed="true">
               <div className="standalone">
-                <StartConversationView model={mockConversationModel}
+                <StartConversationView conversation={mockConversationModel}
                                        client={mockClient}
-                                       notifications={notifications}
-                                       showCallOptionsMenu={true} />
+                                       notifications={notifications} />
+              </div>
+            </Example>
+          </Section>
+
+          <Section name="FailedConversationView">
+            <Example summary="Failed conversation view" dashed="true">
+              <div className="standalone">
+                <FailedConversationView conversation={mockConversationModel}
+                                        client={mockClient}
+                                        notifications={notifications} />
               </div>
             </Example>
           </Section>
 
           <Section name="ConversationView">
             <Example summary="Desktop conversation window" dashed="true"
                      style={{width: "260px", height: "265px"}}>
               <div className="fx-embedded">