Bug 972017 Part 3 - Finish the view flow transition for direct calling for Loop. r=nperriault
authorMark Banner <standard8@mozilla.com>
Thu, 02 Oct 2014 19:55:22 +0100
changeset 218132 4fda0b1548612cc3c7f3aa34180b88945e8c2dff
parent 218131 4d7e58f67c0a2b5f92fbc0987c8eb0f78652d093
child 218133 67279088803419327fb649b0bcab681dabc764e2
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)
reviewersnperriault
bugs972017
milestone34.0a2
Bug 972017 Part 3 - Finish the view flow transition for direct calling for Loop. r=nperriault
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/conversationViews.js
browser/components/loop/content/js/conversationViews.jsx
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/content/shared/js/websocket.js
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/shared/conversationStore_test.js
browser/components/loop/test/shared/websocket_test.js
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
@@ -476,17 +476,18 @@ loop.conversation = (function(mozL10n) {
     propTypes: {
       // XXX Old types required for incoming call view.
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
 
       // XXX New types for OutgoingConversationView
-      store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired
+      store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
     },
 
     getInitialState: function() {
       return this.props.store.attributes;
     },
 
     componentWillMount: function() {
       this.props.store.on("change:outgoing", function() {
@@ -497,30 +498,31 @@ loop.conversation = (function(mozL10n) {
     render: function() {
       // Don't display anything, until we know what type of call we are.
       if (this.state.outgoing === undefined) {
         return null;
       }
 
       if (this.state.outgoing) {
         return (OutgoingConversationView({
-          store: this.props.store}
+          store: this.props.store, 
+          dispatcher: this.props.dispatcher}
         ));
       }
 
       return (IncomingConversationView({
         client: this.props.client, 
         conversation: this.props.conversation, 
         sdk: this.props.sdk}
       ));
     }
   });
 
   /**
-   * Panel initialisation.
+   * Conversation initialisation.
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
     // else to ensure the L10n environment is setup correctly.
     mozL10n.initialize(navigator.mozLoop);
 
     // Plug in an alternate client ID mechanism, as localStorage and cookies
     // don't work in the conversation window
@@ -567,16 +569,17 @@ loop.conversation = (function(mozL10n) {
     });
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
 
     React.renderComponent(ConversationControllerView({
       store: conversationStore, 
       client: client, 
       conversation: conversation, 
+      dispatcher: dispatcher, 
       sdk: window.OT}
     ), document.querySelector('#main'));
 
     dispatcher.dispatch(new loop.shared.actions.GatherCallData({
       callId: callId,
       calleeId: outgoingEmail
     }));
   }
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -476,17 +476,18 @@ loop.conversation = (function(mozL10n) {
     propTypes: {
       // XXX Old types required for incoming call view.
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
 
       // XXX New types for OutgoingConversationView
-      store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired
+      store: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
     },
 
     getInitialState: function() {
       return this.props.store.attributes;
     },
 
     componentWillMount: function() {
       this.props.store.on("change:outgoing", function() {
@@ -498,29 +499,30 @@ loop.conversation = (function(mozL10n) {
       // Don't display anything, until we know what type of call we are.
       if (this.state.outgoing === undefined) {
         return null;
       }
 
       if (this.state.outgoing) {
         return (<OutgoingConversationView
           store={this.props.store}
+          dispatcher={this.props.dispatcher}
         />);
       }
 
       return (<IncomingConversationView
         client={this.props.client}
         conversation={this.props.conversation}
         sdk={this.props.sdk}
       />);
     }
   });
 
   /**
-   * Panel initialisation.
+   * Conversation initialisation.
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
     // else to ensure the L10n environment is setup correctly.
     mozL10n.initialize(navigator.mozLoop);
 
     // Plug in an alternate client ID mechanism, as localStorage and cookies
     // don't work in the conversation window
@@ -567,16 +569,17 @@ loop.conversation = (function(mozL10n) {
     });
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
 
     React.renderComponent(<ConversationControllerView
       store={conversationStore}
       client={client}
       conversation={conversation}
+      dispatcher={dispatcher}
       sdk={window.OT}
     />, document.querySelector('#main'));
 
     dispatcher.dispatch(new loop.shared.actions.GatherCallData({
       callId: callId,
       calleeId: outgoingEmail
     }));
   }
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -5,16 +5,19 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.conversationViews = (function(mozL10n) {
 
   var CALL_STATES = loop.store.CALL_STATES;
+  var CALL_TYPES = loop.shared.utils.CALL_TYPES;
+  var sharedActions = loop.shared.actions;
+  var sharedViews = loop.shared.views;
 
   /**
    * Displays details of the incoming/outgoing conversation
    * (name, link, audio/video type etc).
    *
    * Allows the view to be extended with different buttons and progress
    * via children properties.
    */
@@ -36,91 +39,278 @@ loop.conversationViews = (function(mozL1
   });
 
   /**
    * View for pending conversations. Displays a cancel button and appropriate
    * pending/ringing strings.
    */
   var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
     propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       callState: React.PropTypes.string,
       calleeId: React.PropTypes.string,
+      enableCancelButton: React.PropTypes.bool
+    },
+
+    getDefaultProps: function() {
+      return {
+        enableCancelButton: false
+      };
+    },
+
+    cancelCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.CancelCall());
     },
 
     render: function() {
+      var cx = React.addons.classSet;
       var pendingStateString;
       if (this.props.callState === CALL_STATES.ALERTING) {
         pendingStateString = mozL10n.get("call_progress_ringing_description");
       } else {
         pendingStateString = mozL10n.get("call_progress_connecting_description");
       }
 
+      var btnCancelStyles = cx({
+        "btn": true,
+        "btn-cancel": true,
+        "disabled": !this.props.enableCancelButton
+      });
+
       return (
         ConversationDetailView({calleeId: this.props.calleeId}, 
 
           React.DOM.p({className: "btn-label"}, pendingStateString), 
 
           React.DOM.div({className: "btn-group call-action-group"}, 
             React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
-              React.DOM.button({className: "btn btn-cancel"}, 
+              React.DOM.button({className: btnCancelStyles, 
+                      onClick: this.cancelCall}, 
                 mozL10n.get("initiate_call_cancel_button")
               ), 
             React.DOM.div({className: "fx-embedded-call-button-spacer"})
           )
 
         )
       );
     }
   });
 
   /**
    * Call failed view. Displayed when a call fails.
    */
   var CallFailedView = React.createClass({displayName: 'CallFailedView',
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+    },
+
+    retryCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.RetryCall());
+    },
+
+    cancelCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.CancelCall());
+    },
+
     render: function() {
       return (
         React.DOM.div({className: "call-window"}, 
-          React.DOM.h2(null, mozL10n.get("generic_failure_title"))
+          React.DOM.h2(null, mozL10n.get("generic_failure_title")), 
+
+          React.DOM.p({className: "btn-label"}, mozL10n.get("generic_failure_no_reason2")), 
+
+          React.DOM.div({className: "btn-group call-action-group"}, 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
+              React.DOM.button({className: "btn btn-accept btn-retry", 
+                      onClick: this.retryCall}, 
+                mozL10n.get("retry_call_button")
+              ), 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
+              React.DOM.button({className: "btn btn-cancel", 
+                      onClick: this.cancelCall}, 
+                mozL10n.get("cancel_button")
+              ), 
+            React.DOM.div({className: "fx-embedded-call-button-spacer"})
+          )
+        )
+      );
+    }
+  });
+
+  var OngoingConversationView = React.createClass({displayName: 'OngoingConversationView',
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      video: React.PropTypes.object,
+      audio: React.PropTypes.object
+    },
+
+    getDefaultProps: function() {
+      return {
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
+      };
+    },
+
+    componentDidMount: function() {
+      /**
+       * OT inserts inline styles into the markup. Using a listener for
+       * resize events helps us trigger a full width/height on the element
+       * so that they update to the correct dimensions.
+       * XXX: this should be factored as a mixin.
+       */
+      window.addEventListener('orientationchange', this.updateVideoContainer);
+      window.addEventListener('resize', this.updateVideoContainer);
+    },
+
+    componentWillUnmount: function() {
+      window.removeEventListener('orientationchange', this.updateVideoContainer);
+      window.removeEventListener('resize', this.updateVideoContainer);
+    },
+
+    updateVideoContainer: function() {
+      var localStreamParent = document.querySelector('.local .OT_publisher');
+      var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
+    },
+
+    hangup: function() {
+      this.props.dispatcher.dispatch(
+        new sharedActions.HangupCall());
+    },
+
+    publishStream: function(type, enabled) {
+      // XXX Add this as part of bug 972017.
+    },
+
+    render: function() {
+      var localStreamClasses = React.addons.classSet({
+        local: true,
+        "local-stream": true,
+        "local-stream-audio": !this.props.video.enabled
+      });
+
+      return (
+        React.DOM.div({className: "video-layout-wrapper"}, 
+          React.DOM.div({className: "conversation"}, 
+            React.DOM.div({className: "media nested"}, 
+              React.DOM.div({className: "video_wrapper remote_wrapper"}, 
+                React.DOM.div({className: "video_inner remote"})
+              ), 
+              React.DOM.div({className: localStreamClasses})
+            ), 
+            loop.shared.views.ConversationToolbar({
+              video: this.props.video, 
+              audio: this.props.audio, 
+              publishStream: this.publishStream, 
+              hangup: this.hangup})
+          )
         )
       );
     }
   });
 
   /**
    * Master View Controller for outgoing calls. This manages
    * the different views that need displaying.
    */
   var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
     propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       store: React.PropTypes.instanceOf(
         loop.store.ConversationStore).isRequired
     },
 
     getInitialState: function() {
       return this.props.store.attributes;
     },
 
     componentWillMount: function() {
       this.props.store.on("change", function() {
         this.setState(this.props.store.attributes);
       }, this);
     },
 
-    render: function() {
-      if (this.state.callState === CALL_STATES.TERMINATED) {
-        return (CallFailedView(null));
-      }
+    _closeWindow: function() {
+      window.close();
+    },
+
+    /**
+     * Returns true if the call is in a cancellable state, during call setup.
+     */
+    _isCancellable: function() {
+      return this.state.callState !== CALL_STATES.INIT &&
+             this.state.callState !== CALL_STATES.GATHER;
+    },
+
+    /**
+     * Used to setup and render the feedback view.
+     */
+    _renderFeedbackView: function() {
+      document.title = mozL10n.get("conversation_has_ended");
+
+      // XXX Bug 1076754 Feedback view should be redone in the Flux style.
+      var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref(
+        "feedback.baseUrl");
+
+      var appVersionInfo = navigator.mozLoop.appVersionInfo;
+
+      var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
+        product: navigator.mozLoop.getLoopCharPref("feedback.product"),
+        platform: appVersionInfo.OS,
+        channel: appVersionInfo.channel,
+        version: appVersionInfo.version
+      });
 
-      return (PendingConversationView({
-        callState: this.state.callState, 
-        calleeId: this.state.calleeId}
-      ))
-    }
+      return (
+        sharedViews.FeedbackView({
+          feedbackApiClient: feedbackClient, 
+          onAfterFeedbackReceived: this._closeWindow.bind(this)}
+        )
+      );
+    },
+
+    render: function() {
+      switch (this.state.callState) {
+        case CALL_STATES.CLOSE: {
+          this._closeWindow();
+          return null;
+        }
+        case CALL_STATES.TERMINATED: {
+          return (CallFailedView({
+            dispatcher: this.props.dispatcher}
+          ));
+        }
+        case CALL_STATES.ONGOING: {
+          return (OngoingConversationView({
+            dispatcher: this.props.dispatcher, 
+            video: {enabled: this.state.callType === CALL_TYPES.AUDIO_VIDEO}}
+            )
+          );
+        }
+        case CALL_STATES.FINISHED: {
+          return this._renderFeedbackView();
+        }
+        default: {
+          return (PendingConversationView({
+            dispatcher: this.props.dispatcher, 
+            callState: this.state.callState, 
+            calleeId: this.state.calleeId, 
+            enableCancelButton: this._isCancellable()}
+          ))
+        }
+      }
+    },
   });
 
   return {
     PendingConversationView: PendingConversationView,
     ConversationDetailView: ConversationDetailView,
     CallFailedView: CallFailedView,
+    OngoingConversationView: OngoingConversationView,
     OutgoingConversationView: OutgoingConversationView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -5,16 +5,19 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.conversationViews = (function(mozL10n) {
 
   var CALL_STATES = loop.store.CALL_STATES;
+  var CALL_TYPES = loop.shared.utils.CALL_TYPES;
+  var sharedActions = loop.shared.actions;
+  var sharedViews = loop.shared.views;
 
   /**
    * Displays details of the incoming/outgoing conversation
    * (name, link, audio/video type etc).
    *
    * Allows the view to be extended with different buttons and progress
    * via children properties.
    */
@@ -36,91 +39,278 @@ loop.conversationViews = (function(mozL1
   });
 
   /**
    * View for pending conversations. Displays a cancel button and appropriate
    * pending/ringing strings.
    */
   var PendingConversationView = React.createClass({
     propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       callState: React.PropTypes.string,
       calleeId: React.PropTypes.string,
+      enableCancelButton: React.PropTypes.bool
+    },
+
+    getDefaultProps: function() {
+      return {
+        enableCancelButton: false
+      };
+    },
+
+    cancelCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.CancelCall());
     },
 
     render: function() {
+      var cx = React.addons.classSet;
       var pendingStateString;
       if (this.props.callState === CALL_STATES.ALERTING) {
         pendingStateString = mozL10n.get("call_progress_ringing_description");
       } else {
         pendingStateString = mozL10n.get("call_progress_connecting_description");
       }
 
+      var btnCancelStyles = cx({
+        "btn": true,
+        "btn-cancel": true,
+        "disabled": !this.props.enableCancelButton
+      });
+
       return (
         <ConversationDetailView calleeId={this.props.calleeId}>
 
           <p className="btn-label">{pendingStateString}</p>
 
           <div className="btn-group call-action-group">
             <div className="fx-embedded-call-button-spacer"></div>
-              <button className="btn btn-cancel">
+              <button className={btnCancelStyles}
+                      onClick={this.cancelCall}>
                 {mozL10n.get("initiate_call_cancel_button")}
               </button>
             <div className="fx-embedded-call-button-spacer"></div>
           </div>
 
         </ConversationDetailView>
       );
     }
   });
 
   /**
    * Call failed view. Displayed when a call fails.
    */
   var CallFailedView = React.createClass({
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+    },
+
+    retryCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.RetryCall());
+    },
+
+    cancelCall: function() {
+      this.props.dispatcher.dispatch(new sharedActions.CancelCall());
+    },
+
     render: function() {
       return (
         <div className="call-window">
           <h2>{mozL10n.get("generic_failure_title")}</h2>
+
+          <p className="btn-label">{mozL10n.get("generic_failure_no_reason2")}</p>
+
+          <div className="btn-group call-action-group">
+            <div className="fx-embedded-call-button-spacer"></div>
+              <button className="btn btn-accept btn-retry"
+                      onClick={this.retryCall}>
+                {mozL10n.get("retry_call_button")}
+              </button>
+            <div className="fx-embedded-call-button-spacer"></div>
+              <button className="btn btn-cancel"
+                      onClick={this.cancelCall}>
+                {mozL10n.get("cancel_button")}
+              </button>
+            <div className="fx-embedded-call-button-spacer"></div>
+          </div>
+        </div>
+      );
+    }
+  });
+
+  var OngoingConversationView = React.createClass({
+    propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      video: React.PropTypes.object,
+      audio: React.PropTypes.object
+    },
+
+    getDefaultProps: function() {
+      return {
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
+      };
+    },
+
+    componentDidMount: function() {
+      /**
+       * OT inserts inline styles into the markup. Using a listener for
+       * resize events helps us trigger a full width/height on the element
+       * so that they update to the correct dimensions.
+       * XXX: this should be factored as a mixin.
+       */
+      window.addEventListener('orientationchange', this.updateVideoContainer);
+      window.addEventListener('resize', this.updateVideoContainer);
+    },
+
+    componentWillUnmount: function() {
+      window.removeEventListener('orientationchange', this.updateVideoContainer);
+      window.removeEventListener('resize', this.updateVideoContainer);
+    },
+
+    updateVideoContainer: function() {
+      var localStreamParent = document.querySelector('.local .OT_publisher');
+      var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
+    },
+
+    hangup: function() {
+      this.props.dispatcher.dispatch(
+        new sharedActions.HangupCall());
+    },
+
+    publishStream: function(type, enabled) {
+      // XXX Add this as part of bug 972017.
+    },
+
+    render: function() {
+      var localStreamClasses = React.addons.classSet({
+        local: true,
+        "local-stream": true,
+        "local-stream-audio": !this.props.video.enabled
+      });
+
+      return (
+        <div className="video-layout-wrapper">
+          <div className="conversation">
+            <div className="media nested">
+              <div className="video_wrapper remote_wrapper">
+                <div className="video_inner remote"></div>
+              </div>
+              <div className={localStreamClasses}></div>
+            </div>
+            <loop.shared.views.ConversationToolbar
+              video={this.props.video}
+              audio={this.props.audio}
+              publishStream={this.publishStream}
+              hangup={this.hangup} />
+          </div>
         </div>
       );
     }
   });
 
   /**
    * Master View Controller for outgoing calls. This manages
    * the different views that need displaying.
    */
   var OutgoingConversationView = React.createClass({
     propTypes: {
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       store: React.PropTypes.instanceOf(
         loop.store.ConversationStore).isRequired
     },
 
     getInitialState: function() {
       return this.props.store.attributes;
     },
 
     componentWillMount: function() {
       this.props.store.on("change", function() {
         this.setState(this.props.store.attributes);
       }, this);
     },
 
-    render: function() {
-      if (this.state.callState === CALL_STATES.TERMINATED) {
-        return (<CallFailedView />);
-      }
+    _closeWindow: function() {
+      window.close();
+    },
+
+    /**
+     * Returns true if the call is in a cancellable state, during call setup.
+     */
+    _isCancellable: function() {
+      return this.state.callState !== CALL_STATES.INIT &&
+             this.state.callState !== CALL_STATES.GATHER;
+    },
+
+    /**
+     * Used to setup and render the feedback view.
+     */
+    _renderFeedbackView: function() {
+      document.title = mozL10n.get("conversation_has_ended");
+
+      // XXX Bug 1076754 Feedback view should be redone in the Flux style.
+      var feebackAPIBaseUrl = navigator.mozLoop.getLoopCharPref(
+        "feedback.baseUrl");
+
+      var appVersionInfo = navigator.mozLoop.appVersionInfo;
+
+      var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
+        product: navigator.mozLoop.getLoopCharPref("feedback.product"),
+        platform: appVersionInfo.OS,
+        channel: appVersionInfo.channel,
+        version: appVersionInfo.version
+      });
 
-      return (<PendingConversationView
-        callState={this.state.callState}
-        calleeId={this.state.calleeId}
-      />)
-    }
+      return (
+        <sharedViews.FeedbackView
+          feedbackApiClient={feedbackClient}
+          onAfterFeedbackReceived={this._closeWindow.bind(this)}
+        />
+      );
+    },
+
+    render: function() {
+      switch (this.state.callState) {
+        case CALL_STATES.CLOSE: {
+          this._closeWindow();
+          return null;
+        }
+        case CALL_STATES.TERMINATED: {
+          return (<CallFailedView
+            dispatcher={this.props.dispatcher}
+          />);
+        }
+        case CALL_STATES.ONGOING: {
+          return (<OngoingConversationView
+            dispatcher={this.props.dispatcher}
+            video={{enabled: this.state.callType === CALL_TYPES.AUDIO_VIDEO}}
+            />
+          );
+        }
+        case CALL_STATES.FINISHED: {
+          return this._renderFeedbackView();
+        }
+        default: {
+          return (<PendingConversationView
+            dispatcher={this.props.dispatcher}
+            callState={this.state.callState}
+            calleeId={this.state.calleeId}
+            enableCancelButton={this._isCancellable()}
+          />)
+        }
+      }
+    },
   });
 
   return {
     PendingConversationView: PendingConversationView,
     ConversationDetailView: ConversationDetailView,
     CallFailedView: CallFailedView,
+    OngoingConversationView: OngoingConversationView,
     OutgoingConversationView: OutgoingConversationView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -43,33 +43,45 @@ loop.shared.actions = (function() {
 
     /**
      * Used to cancel call setup.
      */
     CancelCall: Action.define("cancelCall", {
     }),
 
     /**
+     * Used to retry a failed call.
+     */
+    RetryCall: Action.define("retryCall", {
+    }),
+
+    /**
      * Used to initiate connecting of a call with the relevant
      * sessionData.
      */
     ConnectCall: Action.define("connectCall", {
       // This object contains the necessary details for the
       // connection of the websocket, and the SDK
       sessionData: Object
     }),
 
     /**
+     * Used for hanging up the call at the end of a successful call.
+     */
+    HangupCall: Action.define("hangupCall", {
+    }),
+
+    /**
      * Used for notifying of connection progress state changes.
      * The connection refers to the overall connection flow as indicated
      * on the websocket.
      */
     ConnectionProgress: Action.define("connectionProgress", {
-      // The new connection state
-      state: String
+      // The connection state from the websocket.
+      wsState: String
     }),
 
     /**
      * Used for notifying of connection failures.
      */
     ConnectionFailure: Action.define("connectionFailure", {
       // A string relating to the reason the connection failed.
       reason: String
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -5,29 +5,56 @@
 /* global loop:true */
 
 var loop = loop || {};
 loop.store = (function() {
 
   var sharedActions = loop.shared.actions;
   var sharedUtils = loop.shared.utils;
 
+  /**
+   * Websocket states taken from:
+   * https://docs.services.mozilla.com/loop/apis.html#call-progress-state-change-progress
+   */
+  var WS_STATES = {
+    // The call is starting, and the remote party is not yet being alerted.
+    INIT: "init",
+    // The called party is being alerted.
+    ALERTING: "alerting",
+    // The call is no longer being set up and has been aborted for some reason.
+    TERMINATED: "terminated",
+    // The called party has indicated that he has answered the call,
+    // but the media is not yet confirmed.
+    CONNECTING: "connecting",
+    // One of the two parties has indicated successful media set up,
+    // but the other has not yet.
+    HALF_CONNECTED: "half-connected",
+    // Both endpoints have reported successfully establishing media.
+    CONNECTED: "connected"
+  };
+
   var CALL_STATES = {
     // The initial state of the view.
-    INIT: "init",
+    INIT: "cs-init",
     // The store is gathering the call data from the server.
-    GATHER: "gather",
-    // The websocket has connected to the server and is waiting
-    // for the other peer to connect to the websocket.
-    CONNECTING: "connecting",
+    GATHER: "cs-gather",
+    // The initial data has been gathered, the websocket is connecting, or has
+    // connected, and waiting for the other side to connect to the server.
+    CONNECTING: "cs-connecting",
     // The websocket has received information that we're now alerting
     // the peer.
-    ALERTING: "alerting",
+    ALERTING: "cs-alerting",
+    // The call is ongoing.
+    ONGOING: "cs-ongoing",
+    // The call ended successfully.
+    FINISHED: "cs-finished",
+    // The user has finished with the window.
+    CLOSE: "cs-close",
     // The call was terminated due to an issue during connection.
-    TERMINATED: "terminated"
+    TERMINATED: "cs-terminated"
   };
 
 
   var ConversationStore = Backbone.Model.extend({
     defaults: {
       // The current state of the call
       callState: CALL_STATES.INIT,
       // The reason if a call was terminated
@@ -80,17 +107,20 @@ loop.store = (function() {
 
       this.client = options.client;
       this.dispatcher = options.dispatcher;
 
       this.dispatcher.register(this, [
         "connectionFailure",
         "connectionProgress",
         "gatherCallData",
-        "connectCall"
+        "connectCall",
+        "hangupCall",
+        "cancelCall",
+        "retryCall"
       ]);
     },
 
     /**
      * Handles the connection failure action, setting the state to
      * terminated.
      *
      * @param {sharedActions.ConnectionFailure} actionData The action data.
@@ -104,29 +134,39 @@ loop.store = (function() {
 
     /**
      * Handles the connection progress action, setting the next state
      * appropriately.
      *
      * @param {sharedActions.ConnectionProgress} actionData The action data.
      */
     connectionProgress: function(actionData) {
-      // XXX Turn this into a state machine?
-      if (actionData.state === "alerting" &&
-          (this.get("callState") === CALL_STATES.CONNECTING ||
-           this.get("callState") === CALL_STATES.GATHER)) {
-        this.set({
-          callState: CALL_STATES.ALERTING
-        });
-      }
-      if (actionData.state === "connecting" &&
-          this.get("callState") === CALL_STATES.GATHER) {
-        this.set({
-          callState: CALL_STATES.CONNECTING
-        });
+      var callState = this.get("callState");
+
+      switch(actionData.wsState) {
+        case WS_STATES.INIT: {
+          if (callState === CALL_STATES.GATHER) {
+            this.set({callState: CALL_STATES.CONNECTING});
+          }
+          break;
+        }
+        case WS_STATES.ALERTING: {
+          this.set({callState: CALL_STATES.ALERTING});
+          break;
+        }
+        case WS_STATES.CONNECTING:
+        case WS_STATES.HALF_CONNECTED:
+        case WS_STATES.CONNECTED: {
+          this.set({callState: CALL_STATES.ONGOING});
+          break;
+        }
+        default: {
+          console.error("Unexpected websocket state passed to connectionProgress:",
+            actionData.wsState);
+        }
       }
     },
 
     /**
      * Handles the gather call data action, setting the state
      * and starting to get the appropriate data for the type of call.
      *
      * @param {sharedActions.GatherCallData} actionData The action data.
@@ -152,16 +192,73 @@ loop.store = (function() {
      * @param {sharedActions.ConnectCall} actionData The action data.
      */
     connectCall: function(actionData) {
       this.set(actionData.sessionData);
       this._connectWebSocket();
     },
 
     /**
+     * Hangs up an ongoing call.
+     */
+    hangupCall: function() {
+      // XXX Stop the SDK once we add it.
+
+      // Ensure the websocket has been disconnected.
+      if (this._websocket) {
+        // Let the server know the user has hung up.
+        this._websocket.mediaFail();
+        this._ensureWebSocketDisconnected();
+      }
+
+      this.set({callState: CALL_STATES.FINISHED});
+    },
+
+    /**
+     * Cancels a call
+     */
+    cancelCall: function() {
+      var callState = this.get("callState");
+      if (callState === CALL_STATES.TERMINATED) {
+        // All we need to do is close the window.
+        this.set({callState: CALL_STATES.CLOSE});
+        return;
+      }
+
+      if (callState === CALL_STATES.CONNECTING ||
+          callState === CALL_STATES.ALERTING) {
+        if (this._websocket) {
+          // Let the server know the user has hung up.
+          this._websocket.cancel();
+          this._ensureWebSocketDisconnected();
+        }
+        this.set({callState: CALL_STATES.CLOSE});
+        return;
+      }
+
+      console.log("Unsupported cancel in state", callState);
+    },
+
+    /**
+     * Retries a call
+     */
+    retryCall: function() {
+      var callState = this.get("callState");
+      if (callState !== CALL_STATES.TERMINATED) {
+        console.error("Unexpected retry in state", callState);
+        return;
+      }
+
+      this.set({callState: CALL_STATES.GATHER});
+      if (this.get("outgoing")) {
+        this._setupOutgoingCall();
+      }
+    },
+
+    /**
      * Obtains the outgoing call data from the server and handles the
      * result.
      */
     _setupOutgoingCall: function() {
       // XXX For now, we only have one calleeId, so just wrap that in an array.
       this.client.setupOutgoingCall([this.get("calleeId")],
         this.get("callType"),
         function(err, result) {
@@ -187,58 +284,69 @@ loop.store = (function() {
     _connectWebSocket: function() {
       this._websocket = new loop.CallConnectionWebSocket({
         url: this.get("progressURL"),
         callId: this.get("callId"),
         websocketToken: this.get("websocketToken")
       });
 
       this._websocket.promiseConnect().then(
-        function() {
+        function(progressState) {
           this.dispatcher.dispatch(new sharedActions.ConnectionProgress({
             // This is the websocket call state, i.e. waiting for the
             // other end to connect to the server.
-            state: "connecting"
+            wsState: progressState
           }));
         }.bind(this),
         function(error) {
           console.error("Websocket failed to connect", error);
           this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
             reason: "websocket-setup"
           }));
         }.bind(this)
       );
 
-      this._websocket.on("progress", this._handleWebSocketProgress, this);
+      this.listenTo(this._websocket, "progress", this._handleWebSocketProgress);
+    },
+
+    /**
+     * Ensures the websocket gets disconnected.
+     */
+    _ensureWebSocketDisconnected: function() {
+     this.stopListening(this._websocket);
+
+      // Now close the websocket.
+      this._websocket.close();
+      delete this._websocket;
     },
 
     /**
      * Used to handle any progressed received from the websocket. This will
      * dispatch new actions so that the data can be handled appropriately.
      */
     _handleWebSocketProgress: function(progressData) {
       var action;
 
       switch(progressData.state) {
-        case "terminated":
+        case WS_STATES.TERMINATED: {
           action = new sharedActions.ConnectionFailure({
             reason: progressData.reason
           });
           break;
-        case "alerting":
+        }
+        default: {
           action = new sharedActions.ConnectionProgress({
-            state: progressData.state
+            wsState: progressData.state
           });
           break;
-        default:
-          console.warn("Received unexpected state in _handleWebSocketProgress", progressData.state);
-          return;
+        }
       }
 
       this.dispatcher.dispatch(action);
     }
   });
 
   return {
     CALL_STATES: CALL_STATES,
-    ConversationStore: ConversationStore
+    ConversationStore: ConversationStore,
+    WS_STATES: WS_STATES
   };
 })();
--- a/browser/components/loop/content/shared/js/websocket.js
+++ b/browser/components/loop/content/shared/js/websocket.js
@@ -170,16 +170,27 @@ loop.CallConnectionWebSocket = (function
       this._send({
         messageType: "action",
         event: "terminate",
         reason: "cancel"
       });
     },
 
     /**
+     * Notifies the server that something failed during setup.
+     */
+    mediaFail: function() {
+      this._send({
+        messageType: "action",
+        event: "terminate",
+        reason: "media-fail"
+      });
+    },
+
+    /**
      * 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));
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -1,25 +1,28 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 var expect = chai.expect;
 
 describe("loop.conversationViews", function () {
-  var sandbox, oldTitle, view;
+  var sandbox, oldTitle, view, dispatcher;
 
   var CALL_STATES = loop.store.CALL_STATES;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
     oldTitle = document.title;
     sandbox.stub(document.mozL10n, "get", function(x) {
       return x;
     });
+
+    dispatcher = new loop.Dispatcher();
+    sandbox.stub(dispatcher, "dispatch");
   });
 
   afterEach(function() {
     document.title = oldTitle;
     view = undefined;
     sandbox.restore();
   });
 
@@ -48,79 +51,190 @@ describe("loop.conversationViews", funct
       return TestUtils.renderIntoDocument(
         loop.conversationViews.PendingConversationView(props));
     }
 
     it("should set display connecting string when the state is not alerting",
       function() {
         view = mountTestComponent({
           callState: CALL_STATES.CONNECTING,
-          calleeId: "mrsmith"
+          calleeId: "mrsmith",
+          dispatcher: dispatcher
         });
 
         var label = TestUtils.findRenderedDOMComponentWithClass(
           view, "btn-label").props.children;
 
         expect(label).to.have.string("connecting");
     });
 
     it("should set display ringing string when the state is alerting",
       function() {
         view = mountTestComponent({
           callState: CALL_STATES.ALERTING,
-          calleeId: "mrsmith"
+          calleeId: "mrsmith",
+          dispatcher: dispatcher
         });
 
         var label = TestUtils.findRenderedDOMComponentWithClass(
           view, "btn-label").props.children;
 
         expect(label).to.have.string("ringing");
     });
+
+    it("should disable the cancel button if enableCancelButton is false",
+      function() {
+        view = mountTestComponent({
+          callState: CALL_STATES.CONNECTING,
+          calleeId: "mrsmith",
+          dispatcher: dispatcher,
+          enableCancelButton: false
+        });
+
+        var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
+
+        expect(cancelBtn.classList.contains("disabled")).eql(true);
+      });
+
+    it("should enable the cancel button if enableCancelButton is false",
+      function() {
+        view = mountTestComponent({
+          callState: CALL_STATES.CONNECTING,
+          calleeId: "mrsmith",
+          dispatcher: dispatcher,
+          enableCancelButton: true
+        });
+
+        var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
+
+        expect(cancelBtn.classList.contains("disabled")).eql(false);
+      });
+
+    it("should dispatch a cancelCall action when the cancel button is pressed",
+      function() {
+        view = mountTestComponent({
+          callState: CALL_STATES.CONNECTING,
+          calleeId: "mrsmith",
+          dispatcher: dispatcher
+        });
+
+        var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
+
+        React.addons.TestUtils.Simulate.click(cancelBtn);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "cancelCall"));
+      });
+  });
+
+  describe("CallFailedView", function() {
+    function mountTestComponent(props) {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.CallFailedView({
+          dispatcher: dispatcher
+        }));
+    }
+
+    it("should dispatch a retryCall action when the retry button is pressed",
+      function() {
+        view = mountTestComponent();
+
+        var retryBtn = view.getDOMNode().querySelector('.btn-retry');
+
+        React.addons.TestUtils.Simulate.click(retryBtn);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "retryCall"));
+      });
+
+    it("should dispatch a cancelCall action when the cancel button is pressed",
+      function() {
+        view = mountTestComponent();
+
+        var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
+
+        React.addons.TestUtils.Simulate.click(cancelBtn);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "cancelCall"));
+      });
   });
 
   describe("OutgoingConversationView", function() {
     var store;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversationViews.OutgoingConversationView({
           store: store
         }));
     }
 
     beforeEach(function() {
       store = new loop.store.ConversationStore({}, {
-        dispatcher: new loop.Dispatcher(),
+        dispatcher: dispatcher,
         client: {}
       });
+
+      navigator.mozLoop = {
+        getLoopCharPref: function() { return "fake"; },
+        appVersionInfo: sinon.spy()
+      };
+    });
+
+    afterEach(function() {
+      delete navigator.mozLoop;
     });
 
     it("should render the CallFailedView when the call state is 'terminated'",
       function() {
         store.set({callState: CALL_STATES.TERMINATED});
 
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           loop.conversationViews.CallFailedView);
     });
 
-    it("should render the PendingConversationView when the call state is connecting",
+    it("should render the PendingConversationView when the call state is 'init'",
       function() {
-        store.set({callState: CALL_STATES.CONNECTING});
+        store.set({callState: CALL_STATES.INIT});
 
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           loop.conversationViews.PendingConversationView);
     });
 
+    it("should render the OngoingConversationView when the call state is 'ongoing'",
+      function() {
+        store.set({callState: CALL_STATES.ONGOING});
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.conversationViews.OngoingConversationView);
+    });
+
+    it("should render the FeedbackView when the call state is 'finished'",
+      function() {
+        store.set({callState: CALL_STATES.FINISHED});
+
+        view = mountTestComponent();
+
+        TestUtils.findRenderedComponentWithType(view,
+          loop.shared.views.FeedbackView);
+    });
+
     it("should update the rendered views when the state is changed.",
       function() {
-        store.set({callState: CALL_STATES.CONNECTING});
+        store.set({callState: CALL_STATES.INIT});
 
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           loop.conversationViews.PendingConversationView);
 
         store.set({callState: CALL_STATES.TERMINATED});
 
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -2,16 +2,17 @@
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 var expect = chai.expect;
 
 describe("loop.ConversationStore", function () {
   "use strict";
 
   var CALL_STATES = loop.store.CALL_STATES;
+  var WS_STATES = loop.store.WS_STATES;
   var sharedActions = loop.shared.actions;
   var sharedUtils = loop.shared.utils;
   var sandbox, dispatcher, client, store, fakeSessionData;
   var connectPromise, resolveConnectPromise, rejectConnectPromise;
 
   function checkFailures(done, f) {
     try {
       f();
@@ -81,44 +82,77 @@ describe("loop.ConversationStore", funct
         new sharedActions.ConnectionFailure({reason: "fake"}));
 
       expect(store.get("callState")).eql(CALL_STATES.TERMINATED);
       expect(store.get("callStateReason")).eql("fake");
     });
   });
 
   describe("#connectionProgress", function() {
-    describe("progress: connecting", function() {
+    describe("progress: init", function() {
       it("should change the state from 'gather' to 'connecting'", function() {
         store.set({callState: CALL_STATES.GATHER});
 
         dispatcher.dispatch(
-          new sharedActions.ConnectionProgress({state: "connecting"}));
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.INIT}));
 
         expect(store.get("callState")).eql(CALL_STATES.CONNECTING);
       });
     });
 
     describe("progress: alerting", function() {
-      it("should set the state from 'gather' to 'alerting'", function() {
+      it("should change the state from 'gather' to 'alerting'", function() {
         store.set({callState: CALL_STATES.GATHER});
 
         dispatcher.dispatch(
-          new sharedActions.ConnectionProgress({state: "alerting"}));
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.ALERTING}));
 
         expect(store.get("callState")).eql(CALL_STATES.ALERTING);
       });
 
-      it("should set the state from 'connecting' to 'alerting'", function() {
-        store.set({callState: CALL_STATES.CONNECTING});
+      it("should change the state from 'init' to 'alerting'", function() {
+        store.set({callState: CALL_STATES.INIT});
+
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.ALERTING}));
+
+        expect(store.get("callState")).eql(CALL_STATES.ALERTING);
+      });
+    });
+
+    describe("progress: connecting", function() {
+      it("should change the state to 'ongoing'", function() {
+        store.set({callState: CALL_STATES.ALERTING});
 
         dispatcher.dispatch(
-          new sharedActions.ConnectionProgress({state: "alerting"}));
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
+
+        expect(store.get("callState")).eql(CALL_STATES.ONGOING);
+      });
+    });
+
+    describe("progress: half-connected", function() {
+      it("should change the state to 'ongoing'", function() {
+        store.set({callState: CALL_STATES.ALERTING});
+
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.HALF_CONNECTED}));
 
-        expect(store.get("callState")).eql(CALL_STATES.ALERTING);
+        expect(store.get("callState")).eql(CALL_STATES.ONGOING);
+      });
+    });
+
+    describe("progress: connecting", function() {
+      it("should change the state to 'ongoing'", function() {
+        store.set({callState: CALL_STATES.ALERTING});
+
+        dispatcher.dispatch(
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTED}));
+
+        expect(store.get("callState")).eql(CALL_STATES.ONGOING);
       });
     });
   });
 
   describe("#gatherCallData", function() {
     beforeEach(function() {
       store.set({callState: CALL_STATES.INIT});
     });
@@ -245,26 +279,26 @@ describe("loop.ConversationStore", funct
       beforeEach(function() {
         dispatcher.dispatch(
           new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
         sandbox.stub(dispatcher, "dispatch");
       });
 
       it("should dispatch a connection progress action on success", function(done) {
-        resolveConnectPromise();
+        resolveConnectPromise(WS_STATES.INIT);
 
         connectPromise.then(function() {
           checkFailures(done, function() {
             sinon.assert.calledOnce(dispatcher.dispatch);
             // Can't use instanceof here, as that matches any action
             sinon.assert.calledWithMatch(dispatcher.dispatch,
               sinon.match.hasOwn("name", "connectionProgress"));
             sinon.assert.calledWithMatch(dispatcher.dispatch,
-              sinon.match.hasOwn("state", "connecting"));
+              sinon.match.hasOwn("wsState", WS_STATES.INIT));
           });
         }, function() {
           done(new Error("Promise should have been resolve, not rejected"));
         });
       });
 
       it("should dispatch a connection failure action on failure", function(done) {
         rejectConnectPromise();
@@ -277,45 +311,146 @@ describe("loop.ConversationStore", funct
             // Can't use instanceof here, as that matches any action
             sinon.assert.calledWithMatch(dispatcher.dispatch,
               sinon.match.hasOwn("name", "connectionFailure"));
             sinon.assert.calledWithMatch(dispatcher.dispatch,
               sinon.match.hasOwn("reason", "websocket-setup"));
            });
         });
       });
+    });
+  });
 
+  describe("#hangupCall", function() {
+    var wsMediaFailSpy, wsCloseSpy;
+    beforeEach(function() {
+      wsMediaFailSpy = sinon.spy();
+      wsCloseSpy = sinon.spy();
+
+      store._websocket = {
+        mediaFail: wsMediaFailSpy,
+        close: wsCloseSpy
+      };
+      store.set({callState: CALL_STATES.ONGOING});
+    });
+
+    it("should send a media-fail message to the websocket if it is open", function() {
+      dispatcher.dispatch(new sharedActions.HangupCall());
+
+      sinon.assert.calledOnce(wsMediaFailSpy);
+    });
+
+    it("should ensure the websocket is closed", function() {
+      dispatcher.dispatch(new sharedActions.HangupCall());
+
+      sinon.assert.calledOnce(wsCloseSpy);
+    });
+
+    it("should set the callState to finished", function() {
+      dispatcher.dispatch(new sharedActions.HangupCall());
+
+      expect(store.get("callState")).eql(CALL_STATES.FINISHED);
+    });
+  });
+
+  describe("#cancelCall", function() {
+    it("should set the state to close if the call has terminated already", function() {
+      store.set({callState: CALL_STATES.TERMINATED});
+
+      dispatcher.dispatch(new sharedActions.CancelCall());
+
+      expect(store.get("callState")).eql(CALL_STATES.CLOSE);
+    });
+
+    describe("whilst connecting", function() {
+      var wsCancelSpy, wsCloseSpy;
+      beforeEach(function() {
+        wsCancelSpy = sinon.spy();
+        wsCloseSpy = sinon.spy();
+
+        store._websocket = {
+          cancel: wsCancelSpy,
+          close: wsCloseSpy
+        };
+        store.set({callState: CALL_STATES.CONNECTING});
+      });
+
+      it("should send a cancel message to the websocket if it is open", function() {
+        dispatcher.dispatch(new sharedActions.CancelCall());
+
+        sinon.assert.calledOnce(wsCancelSpy);
+      });
+
+      it("should ensure the websocket is closed", function() {
+        dispatcher.dispatch(new sharedActions.CancelCall());
+
+        sinon.assert.calledOnce(wsCloseSpy);
+      });
+
+      it("should set the state to close if the call is connecting", function() {
+        dispatcher.dispatch(new sharedActions.CancelCall());
+
+        expect(store.get("callState")).eql(CALL_STATES.CLOSE);
+      });
+    });
+  });
+
+  describe("#retryCall", function() {
+    it("should set the state to gather", function() {
+      store.set({callState: CALL_STATES.TERMINATED});
+
+      dispatcher.dispatch(new sharedActions.RetryCall());
+
+      expect(store.get("callState")).eql(CALL_STATES.GATHER);
+    });
+
+    it("should request the outgoing call data", function() {
+      store.set({
+        callState: CALL_STATES.TERMINATED,
+        outgoing: true,
+        callType: sharedUtils.CALL_TYPES.AUDIO_VIDEO,
+        calleeId: "fake"
+      });
+
+      dispatcher.dispatch(new sharedActions.RetryCall());
+
+      sinon.assert.calledOnce(client.setupOutgoingCall);
+      sinon.assert.calledWith(client.setupOutgoingCall,
+        ["fake"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
     });
   });
 
   describe("Events", function() {
     describe("Websocket progress", function() {
       beforeEach(function() {
         dispatcher.dispatch(
           new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
         sandbox.stub(dispatcher, "dispatch");
       });
 
       it("should dispatch a connection failure action on 'terminate'", function() {
-        store._websocket.trigger("progress", {state: "terminated", reason: "reject"});
+        store._websocket.trigger("progress", {
+          state: WS_STATES.TERMINATED,
+          reason: "reject"
+        });
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         // Can't use instanceof here, as that matches any action
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "connectionFailure"));
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("reason", "reject"));
       });
 
       it("should dispatch a connection progress action on 'alerting'", function() {
-        store._websocket.trigger("progress", {state: "alerting"});
+        store._websocket.trigger("progress", {state: WS_STATES.ALERTING});
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         // Can't use instanceof here, as that matches any action
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "connectionProgress"));
         sinon.assert.calledWithMatch(dispatcher.dispatch,
-          sinon.match.hasOwn("state", "alerting"));
+          sinon.match.hasOwn("wsState", WS_STATES.ALERTING));
       });
     });
   });
 });
--- a/browser/components/loop/test/shared/websocket_test.js
+++ b/browser/components/loop/test/shared/websocket_test.js
@@ -201,16 +201,32 @@ describe("loop.CallConnectionWebSocket",
           sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
             messageType: "action",
             event: "terminate",
             reason: "cancel"
           }));
         });
     });
 
+    describe("#mediaFail", function() {
+      it("should send a terminate message to the server with a reason of media-fail",
+        function() {
+          callWebSocket.promiseConnect();
+
+          callWebSocket.mediaFail();
+
+          sinon.assert.calledOnce(dummySocket.send);
+          sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
+            messageType: "action",
+            event: "terminate",
+            reason: "media-fail"
+          }));
+        });
+    });
+
     describe("Events", function() {
       beforeEach(function() {
         sandbox.stub(callWebSocket, "trigger");
 
         callWebSocket.promiseConnect();
       });
 
       describe("Progress", function() {
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -5,22 +5,27 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* jshint newcap:false */
 /* global loop:true, React */
 
 (function() {
   "use strict";
 
+  // Stop the default init functions running to avoid conflicts.
+  document.removeEventListener('DOMContentLoaded', loop.panel.init);
+  document.removeEventListener('DOMContentLoaded', loop.conversation.init);
+
   // 1. Desktop components
   // 1.1 Panel
   var PanelView = loop.panel.PanelView;
   // 1.2. Conversation Window
   var IncomingCallView = loop.conversation.IncomingCallView;
   var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
+  var CallFailedView = loop.conversationViews.CallFailedView;
 
   // 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;
@@ -244,16 +249,25 @@
             Example({summary: "Connecting", dashed: "true", 
                      style: {width: "260px", height: "265px"}}, 
               React.DOM.div({className: "fx-embedded"}, 
                 DesktopPendingConversationView({callState: "gather", calleeId: "Mr Smith"})
               )
             )
           ), 
 
+          Section({name: "CallFailedView"}, 
+            Example({summary: "Call Failed", dashed: "true", 
+                     style: {width: "260px", height: "265px"}}, 
+              React.DOM.div({className: "fx-embedded"}, 
+                CallFailedView(null)
+              )
+            )
+          ), 
+
           Section({name: "StartConversationView"}, 
             Example({summary: "Start conversation view", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
                 StartConversationView({conversation: mockConversationModel, 
                                        client: mockClient, 
                                        notifications: notifications})
               )
             )
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -5,22 +5,27 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* jshint newcap:false */
 /* global loop:true, React */
 
 (function() {
   "use strict";
 
+  // Stop the default init functions running to avoid conflicts.
+  document.removeEventListener('DOMContentLoaded', loop.panel.init);
+  document.removeEventListener('DOMContentLoaded', loop.conversation.init);
+
   // 1. Desktop components
   // 1.1 Panel
   var PanelView = loop.panel.PanelView;
   // 1.2. Conversation Window
   var IncomingCallView = loop.conversation.IncomingCallView;
   var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
+  var CallFailedView = loop.conversationViews.CallFailedView;
 
   // 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;
@@ -244,16 +249,25 @@
             <Example summary="Connecting" dashed="true"
                      style={{width: "260px", height: "265px"}}>
               <div className="fx-embedded">
                 <DesktopPendingConversationView callState={"gather"} calleeId="Mr Smith" />
               </div>
             </Example>
           </Section>
 
+          <Section name="CallFailedView">
+            <Example summary="Call Failed" dashed="true"
+                     style={{width: "260px", height: "265px"}}>
+              <div className="fx-embedded">
+                <CallFailedView />
+              </div>
+            </Example>
+          </Section>
+
           <Section name="StartConversationView">
             <Example summary="Start conversation view" dashed="true">
               <div className="standalone">
                 <StartConversationView conversation={mockConversationModel}
                                        client={mockClient}
                                        notifications={notifications} />
               </div>
             </Example>