Bug 974873 - Add feedback form to Loop standalone conversation window. r=Standard8
authorNicolas Perriault <nperriault@gmail.com>
Mon, 22 Sep 2014 13:40:57 +0100
changeset 218088 42facc20012909005a392e7ccbaee6505d75ab92
parent 218087 e3eb0b80e2177b80fe6fef435f5c45594632e78a
child 218089 d7ae7b0e8de15cc1f762a837da21e0a4c8dcd06c
push idunknown
push userunknown
push dateunknown
reviewersStandard8
bugs974873
milestone34.0a2
Bug 974873 - Add feedback form to Loop standalone conversation window. r=Standard8
browser/components/loop/content/js/client.js
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/shared/js/feedbackApiClient.js
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/standalone/Makefile
browser/components/loop/standalone/README.md
browser/components/loop/standalone/content/css/webapp.css
browser/components/loop/standalone/content/index.html
browser/components/loop/standalone/content/js/standaloneClient.js
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/standalone/content/l10n/loop.en-US.properties
browser/components/loop/standalone/server.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/shared/feedbackApiClient_test.js
browser/components/loop/test/shared/router_test.js
browser/components/loop/test/shared/views_test.js
browser/components/loop/test/standalone/index.html
browser/components/loop/test/standalone/webapp_test.js
browser/components/loop/ui/fake-l10n.js
browser/components/loop/ui/fake-mozLoop.js
browser/components/loop/ui/ui-showcase.css
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -109,17 +109,17 @@ loop.Client = (function($) {
      */
     _requestCallUrlInternal: function(nickname, cb) {
       var sessionType;
       if (this.mozLoop.userProfile) {
         sessionType = this.mozLoop.LOOP_SESSION_TYPE.FXA;
       } else {
         sessionType = this.mozLoop.LOOP_SESSION_TYPE.GUEST;
       }
-      
+
       this.mozLoop.hawkRequest(sessionType, "/call-url/", "POST",
                                {callerId: nickname},
         function (error, responseText) {
           if (error) {
             this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", false);
             this._failureHandler(cb, error);
             return;
           }
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -401,16 +401,17 @@ loop.conversation = (function(OT, mozL10
         return;
       }
 
       var callType = this._conversation.get("selectedCallType");
       var videoStream = callType === "audio" ? false : true;
 
       /*jshint newcap:false*/
       this.loadReactComponent(sharedViews.ConversationView({
+        initiate: true,
         sdk: OT,
         model: this._conversation,
         video: {enabled: videoStream}
       }));
     },
 
     /**
      * Handles a error starting the session
@@ -435,17 +436,18 @@ loop.conversation = (function(OT, mozL10
       var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
         product: navigator.mozLoop.getLoopCharPref("feedback.product"),
         platform: appVersionInfo.OS,
         channel: appVersionInfo.channel,
         version: appVersionInfo.version
       });
 
       this.loadReactComponent(sharedViews.FeedbackView({
-        feedbackApiClient: feedbackClient
+        feedbackApiClient: feedbackClient,
+        onAfterFeedbackReceived: window.close.bind(window)
       }));
     }
   });
 
   /**
    * Panel initialisation.
    */
   function init() {
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -401,16 +401,17 @@ loop.conversation = (function(OT, mozL10
         return;
       }
 
       var callType = this._conversation.get("selectedCallType");
       var videoStream = callType === "audio" ? false : true;
 
       /*jshint newcap:false*/
       this.loadReactComponent(sharedViews.ConversationView({
+        initiate: true,
         sdk: OT,
         model: this._conversation,
         video: {enabled: videoStream}
       }));
     },
 
     /**
      * Handles a error starting the session
@@ -435,17 +436,18 @@ loop.conversation = (function(OT, mozL10
       var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
         product: navigator.mozLoop.getLoopCharPref("feedback.product"),
         platform: appVersionInfo.OS,
         channel: appVersionInfo.channel,
         version: appVersionInfo.version
       });
 
       this.loadReactComponent(sharedViews.FeedbackView({
-        feedbackApiClient: feedbackClient
+        feedbackApiClient: feedbackClient,
+        onAfterFeedbackReceived: window.close.bind(window)
       }));
     }
   });
 
   /**
    * Panel initialisation.
    */
   function init() {
--- a/browser/components/loop/content/shared/js/feedbackApiClient.js
+++ b/browser/components/loop/content/shared/js/feedbackApiClient.js
@@ -46,17 +46,18 @@ loop.FeedbackAPIClient = (function($, _)
      */
     _supportedFields: ["happy",
                        "category",
                        "description",
                        "product",
                        "platform",
                        "version",
                        "channel",
-                       "user_agent"],
+                       "user_agent",
+                       "url"],
 
     /**
      * Creates a formatted payload object compliant with the Feedback API spec
      * against validated field data.
      *
      * @param  {Object} fields Feedback initial values.
      * @return {Object}        Formatted payload object.
      * @throws {Error}         If provided values are invalid
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -25,35 +25,37 @@ loop.shared.views = (function(_, OT, l10
    * - {Function} action  Function to be executed on click.
    * - {Enabled}  enabled Stream activation status (default: true).
    */
   var MediaControlButton = React.createClass({displayName: 'MediaControlButton',
     propTypes: {
       scope: React.PropTypes.string.isRequired,
       type: React.PropTypes.string.isRequired,
       action: React.PropTypes.func.isRequired,
-      enabled: React.PropTypes.bool.isRequired
+      enabled: React.PropTypes.bool.isRequired,
+      visible: React.PropTypes.bool.isRequired
     },
 
     getDefaultProps: function() {
-      return {enabled: true};
+      return {enabled: true, visible: true};
     },
 
     handleClick: function() {
       this.props.action();
     },
 
     _getClasses: function() {
       var cx = React.addons.classSet;
       // classes
       var classesObj = {
         "btn": true,
         "media-control": true,
         "local-media": this.props.scope === "local",
-        "muted": !this.props.enabled
+        "muted": !this.props.enabled,
+        "hide": !this.props.visible
       };
       classesObj["btn-mute-" + this.props.type] = true;
       return cx(classesObj);
     },
 
     _getTitle: function(enabled) {
       var prefix = this.props.enabled ? "mute" : "unmute";
       var suffix = "button_title";
@@ -73,18 +75,18 @@ loop.shared.views = (function(_, OT, l10
   });
 
   /**
    * Conversation controls.
    */
   var ConversationToolbar = React.createClass({displayName: 'ConversationToolbar',
     getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     propTypes: {
       video: React.PropTypes.object.isRequired,
       audio: React.PropTypes.object.isRequired,
       hangup: React.PropTypes.func.isRequired,
       publishStream: React.PropTypes.func.isRequired
@@ -98,97 +100,108 @@ loop.shared.views = (function(_, OT, l10
       this.props.publishStream("video", !this.props.video.enabled);
     },
 
     handleToggleAudio: function() {
       this.props.publishStream("audio", !this.props.audio.enabled);
     },
 
     render: function() {
-      /* jshint ignore:start */
+      var cx = React.addons.classSet;
       return (
         React.DOM.ul({className: "conversation-toolbar"}, 
           React.DOM.li({className: "conversation-toolbar-btn-box"}, 
             React.DOM.button({className: "btn btn-hangup", onClick: this.handleClickHangup, 
                     title: l10n.get("hangup_button_title")}, 
               l10n.get("hangup_button_caption2")
             )
           ), 
           React.DOM.li({className: "conversation-toolbar-btn-box"}, 
             MediaControlButton({action: this.handleToggleVideo, 
                                 enabled: this.props.video.enabled, 
+                                visible: this.props.video.visible, 
                                 scope: "local", type: "video"})
           ), 
           React.DOM.li({className: "conversation-toolbar-btn-box"}, 
             MediaControlButton({action: this.handleToggleAudio, 
                                 enabled: this.props.audio.enabled, 
+                                visible: this.props.audio.visible, 
                                 scope: "local", type: "audio"})
           )
         )
       );
-      /* jshint ignore:end */
     }
   });
 
+  /**
+   * Conversation view.
+   */
   var ConversationView = React.createClass({displayName: 'ConversationView',
     mixins: [Backbone.Events],
 
     propTypes: {
       sdk: React.PropTypes.object.isRequired,
-      model: React.PropTypes.object.isRequired
+      video: React.PropTypes.object,
+      audio: React.PropTypes.object,
+      initiate: React.PropTypes.bool
     },
 
     // height set to 100%" to fix video layout on Google Chrome
     // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
     publisherConfig: {
       insertMode: "append",
       width: "100%",
       height: "100%",
       style: {
         bugDisplayMode: "off",
         buttonDisplayMode: "off",
         nameDisplayMode: "off"
       }
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        initiate: true,
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     getInitialState: function() {
       return {
         video: this.props.video,
         audio: this.props.audio
       };
     },
 
     componentWillMount: function() {
-      this.publisherConfig.publishVideo = this.props.video.enabled;
+      if (this.props.initiate) {
+        this.publisherConfig.publishVideo = this.props.video.enabled;
+      }
     },
 
     componentDidMount: function() {
-      this.listenTo(this.props.model, "session:connected",
-                                      this.startPublishing);
-      this.listenTo(this.props.model, "session:stream-created",
-                                      this._streamCreated);
-      this.listenTo(this.props.model, ["session:peer-hungup",
-                                       "session:network-disconnected",
-                                       "session:ended"].join(" "),
-                                       this.stopPublishing);
-
-      this.props.model.startSession();
+      if (this.props.initiate) {
+        this.listenTo(this.props.model, "session:connected",
+                                        this.startPublishing);
+        this.listenTo(this.props.model, "session:stream-created",
+                                        this._streamCreated);
+        this.listenTo(this.props.model, ["session:peer-hungup",
+                                         "session:network-disconnected",
+                                         "session:ended"].join(" "),
+                                         this.stopPublishing);
+        this.props.model.startSession();
+      }
 
       /**
        * 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);
     },
 
     updateVideoContainer: function() {
       var localStreamParent = document.querySelector('.local .OT_publisher');
       var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
       if (localStreamParent) {
@@ -277,20 +290,22 @@ loop.shared.views = (function(_, OT, l10
         this.setState({video: {enabled: enabled}});
       }
     },
 
     /**
      * Unpublishes local stream.
      */
     stopPublishing: function() {
-      // Unregister listeners for publisher events
-      this.stopListening(this.publisher);
+      if (this.publisher) {
+        // Unregister listeners for publisher events
+        this.stopListening(this.publisher);
 
-      this.props.model.session.unpublish(this.publisher);
+        this.props.model.session.unpublish(this.publisher);
+      }
     },
 
     render: function() {
       var localStreamClasses = React.addons.classSet({
         local: true,
         "local-stream": true,
         "local-stream-audio": !this.state.video.enabled
       });
@@ -357,17 +372,17 @@ loop.shared.views = (function(_, OT, l10
       sendFeedback: React.PropTypes.func,
       reset:        React.PropTypes.func
     },
 
     getInitialState: function() {
       return {category: "", description: ""};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {pending: false};
     },
 
     _getCategories: function() {
       return {
         audio_quality: l10n.get("feedback_category_audio_quality"),
         video_quality: l10n.get("feedback_category_video_quality"),
         disconnected : l10n.get("feedback_category_was_disconnected"),
@@ -462,18 +477,26 @@ loop.shared.views = (function(_, OT, l10
           )
         )
       );
     }
   });
 
   /**
    * Feedback received view.
+   *
+   * Props:
+   * - {Function} onAfterFeedbackReceived Function to execute after the
+   *   WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS timeout has elapsed
    */
   var FeedbackReceived = React.createClass({displayName: 'FeedbackReceived',
+    propTypes: {
+      onAfterFeedbackReceived: React.PropTypes.func
+    },
+
     getInitialState: function() {
       return {countdown: WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS};
     },
 
     componentDidMount: function() {
       this._timer = setInterval(function() {
         this.setState({countdown: this.state.countdown - 1});
       }.bind(this), 1000);
@@ -483,17 +506,19 @@ loop.shared.views = (function(_, OT, l10
       if (this._timer) {
         clearInterval(this._timer);
       }
     },
 
     render: function() {
       if (this.state.countdown < 1) {
         clearInterval(this._timer);
-        window.close();
+        if (this.props.onAfterFeedbackReceived) {
+          this.props.onAfterFeedbackReceived();
+        }
       }
       return (
         FeedbackLayout({title: l10n.get("feedback_thank_you_heading")}, 
           React.DOM.p({className: "info thank-you"}, 
             l10n.get("feedback_window_will_close_in2", {
               countdown: this.state.countdown,
               num: this.state.countdown
             }))
@@ -504,25 +529,26 @@ loop.shared.views = (function(_, OT, l10
 
   /**
    * Feedback view.
    */
   var FeedbackView = React.createClass({displayName: 'FeedbackView',
     propTypes: {
       // A loop.FeedbackAPIClient instance
       feedbackApiClient: React.PropTypes.object.isRequired,
+      onAfterFeedbackReceived: React.PropTypes.func,
       // The current feedback submission flow step name
       step: React.PropTypes.oneOf(["start", "form", "finished"])
     },
 
     getInitialState: function() {
       return {pending: false, step: this.props.step || "start"};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {step: "start"};
     },
 
     reset: function() {
       this.setState(this.getInitialState());
     },
 
     handleHappyClick: function() {
@@ -547,17 +573,20 @@ loop.shared.views = (function(_, OT, l10
         console.error("Unable to send user feedback", err);
       }
       this.setState({pending: false, step: "finished"});
     },
 
     render: function() {
       switch(this.state.step) {
         case "finished":
-          return FeedbackReceived(null);
+          return (
+            FeedbackReceived({
+              onAfterFeedbackReceived: this.props.onAfterFeedbackReceived})
+          );
         case "form":
           return FeedbackForm({feedbackApiClient: this.props.feedbackApiClient, 
                                sendFeedback: this.sendFeedback, 
                                reset: this.reset, 
                                pending: this.state.pending});
         default:
           return (
             FeedbackLayout({title: 
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -25,35 +25,37 @@ loop.shared.views = (function(_, OT, l10
    * - {Function} action  Function to be executed on click.
    * - {Enabled}  enabled Stream activation status (default: true).
    */
   var MediaControlButton = React.createClass({
     propTypes: {
       scope: React.PropTypes.string.isRequired,
       type: React.PropTypes.string.isRequired,
       action: React.PropTypes.func.isRequired,
-      enabled: React.PropTypes.bool.isRequired
+      enabled: React.PropTypes.bool.isRequired,
+      visible: React.PropTypes.bool.isRequired
     },
 
     getDefaultProps: function() {
-      return {enabled: true};
+      return {enabled: true, visible: true};
     },
 
     handleClick: function() {
       this.props.action();
     },
 
     _getClasses: function() {
       var cx = React.addons.classSet;
       // classes
       var classesObj = {
         "btn": true,
         "media-control": true,
         "local-media": this.props.scope === "local",
-        "muted": !this.props.enabled
+        "muted": !this.props.enabled,
+        "hide": !this.props.visible
       };
       classesObj["btn-mute-" + this.props.type] = true;
       return cx(classesObj);
     },
 
     _getTitle: function(enabled) {
       var prefix = this.props.enabled ? "mute" : "unmute";
       var suffix = "button_title";
@@ -73,18 +75,18 @@ loop.shared.views = (function(_, OT, l10
   });
 
   /**
    * Conversation controls.
    */
   var ConversationToolbar = React.createClass({
     getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     propTypes: {
       video: React.PropTypes.object.isRequired,
       audio: React.PropTypes.object.isRequired,
       hangup: React.PropTypes.func.isRequired,
       publishStream: React.PropTypes.func.isRequired
@@ -98,97 +100,108 @@ loop.shared.views = (function(_, OT, l10
       this.props.publishStream("video", !this.props.video.enabled);
     },
 
     handleToggleAudio: function() {
       this.props.publishStream("audio", !this.props.audio.enabled);
     },
 
     render: function() {
-      /* jshint ignore:start */
+      var cx = React.addons.classSet;
       return (
         <ul className="conversation-toolbar">
           <li className="conversation-toolbar-btn-box">
             <button className="btn btn-hangup" onClick={this.handleClickHangup}
                     title={l10n.get("hangup_button_title")}>
               {l10n.get("hangup_button_caption2")}
             </button>
           </li>
           <li className="conversation-toolbar-btn-box">
             <MediaControlButton action={this.handleToggleVideo}
                                 enabled={this.props.video.enabled}
+                                visible={this.props.video.visible}
                                 scope="local" type="video" />
           </li>
           <li className="conversation-toolbar-btn-box">
             <MediaControlButton action={this.handleToggleAudio}
                                 enabled={this.props.audio.enabled}
+                                visible={this.props.audio.visible}
                                 scope="local" type="audio" />
           </li>
         </ul>
       );
-      /* jshint ignore:end */
     }
   });
 
+  /**
+   * Conversation view.
+   */
   var ConversationView = React.createClass({
     mixins: [Backbone.Events],
 
     propTypes: {
       sdk: React.PropTypes.object.isRequired,
-      model: React.PropTypes.object.isRequired
+      video: React.PropTypes.object,
+      audio: React.PropTypes.object,
+      initiate: React.PropTypes.bool
     },
 
     // height set to 100%" to fix video layout on Google Chrome
     // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
     publisherConfig: {
       insertMode: "append",
       width: "100%",
       height: "100%",
       style: {
         bugDisplayMode: "off",
         buttonDisplayMode: "off",
         nameDisplayMode: "off"
       }
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        initiate: true,
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     getInitialState: function() {
       return {
         video: this.props.video,
         audio: this.props.audio
       };
     },
 
     componentWillMount: function() {
-      this.publisherConfig.publishVideo = this.props.video.enabled;
+      if (this.props.initiate) {
+        this.publisherConfig.publishVideo = this.props.video.enabled;
+      }
     },
 
     componentDidMount: function() {
-      this.listenTo(this.props.model, "session:connected",
-                                      this.startPublishing);
-      this.listenTo(this.props.model, "session:stream-created",
-                                      this._streamCreated);
-      this.listenTo(this.props.model, ["session:peer-hungup",
-                                       "session:network-disconnected",
-                                       "session:ended"].join(" "),
-                                       this.stopPublishing);
-
-      this.props.model.startSession();
+      if (this.props.initiate) {
+        this.listenTo(this.props.model, "session:connected",
+                                        this.startPublishing);
+        this.listenTo(this.props.model, "session:stream-created",
+                                        this._streamCreated);
+        this.listenTo(this.props.model, ["session:peer-hungup",
+                                         "session:network-disconnected",
+                                         "session:ended"].join(" "),
+                                         this.stopPublishing);
+        this.props.model.startSession();
+      }
 
       /**
        * 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);
     },
 
     updateVideoContainer: function() {
       var localStreamParent = document.querySelector('.local .OT_publisher');
       var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
       if (localStreamParent) {
@@ -277,20 +290,22 @@ loop.shared.views = (function(_, OT, l10
         this.setState({video: {enabled: enabled}});
       }
     },
 
     /**
      * Unpublishes local stream.
      */
     stopPublishing: function() {
-      // Unregister listeners for publisher events
-      this.stopListening(this.publisher);
+      if (this.publisher) {
+        // Unregister listeners for publisher events
+        this.stopListening(this.publisher);
 
-      this.props.model.session.unpublish(this.publisher);
+        this.props.model.session.unpublish(this.publisher);
+      }
     },
 
     render: function() {
       var localStreamClasses = React.addons.classSet({
         local: true,
         "local-stream": true,
         "local-stream-audio": !this.state.video.enabled
       });
@@ -357,17 +372,17 @@ loop.shared.views = (function(_, OT, l10
       sendFeedback: React.PropTypes.func,
       reset:        React.PropTypes.func
     },
 
     getInitialState: function() {
       return {category: "", description: ""};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {pending: false};
     },
 
     _getCategories: function() {
       return {
         audio_quality: l10n.get("feedback_category_audio_quality"),
         video_quality: l10n.get("feedback_category_video_quality"),
         disconnected : l10n.get("feedback_category_was_disconnected"),
@@ -462,18 +477,26 @@ loop.shared.views = (function(_, OT, l10
           </form>
         </FeedbackLayout>
       );
     }
   });
 
   /**
    * Feedback received view.
+   *
+   * Props:
+   * - {Function} onAfterFeedbackReceived Function to execute after the
+   *   WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS timeout has elapsed
    */
   var FeedbackReceived = React.createClass({
+    propTypes: {
+      onAfterFeedbackReceived: React.PropTypes.func
+    },
+
     getInitialState: function() {
       return {countdown: WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS};
     },
 
     componentDidMount: function() {
       this._timer = setInterval(function() {
         this.setState({countdown: this.state.countdown - 1});
       }.bind(this), 1000);
@@ -483,17 +506,19 @@ loop.shared.views = (function(_, OT, l10
       if (this._timer) {
         clearInterval(this._timer);
       }
     },
 
     render: function() {
       if (this.state.countdown < 1) {
         clearInterval(this._timer);
-        window.close();
+        if (this.props.onAfterFeedbackReceived) {
+          this.props.onAfterFeedbackReceived();
+        }
       }
       return (
         <FeedbackLayout title={l10n.get("feedback_thank_you_heading")}>
           <p className="info thank-you">{
             l10n.get("feedback_window_will_close_in2", {
               countdown: this.state.countdown,
               num: this.state.countdown
             })}</p>
@@ -504,25 +529,26 @@ loop.shared.views = (function(_, OT, l10
 
   /**
    * Feedback view.
    */
   var FeedbackView = React.createClass({
     propTypes: {
       // A loop.FeedbackAPIClient instance
       feedbackApiClient: React.PropTypes.object.isRequired,
+      onAfterFeedbackReceived: React.PropTypes.func,
       // The current feedback submission flow step name
       step: React.PropTypes.oneOf(["start", "form", "finished"])
     },
 
     getInitialState: function() {
       return {pending: false, step: this.props.step || "start"};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {step: "start"};
     },
 
     reset: function() {
       this.setState(this.getInitialState());
     },
 
     handleHappyClick: function() {
@@ -547,17 +573,20 @@ loop.shared.views = (function(_, OT, l10
         console.error("Unable to send user feedback", err);
       }
       this.setState({pending: false, step: "finished"});
     },
 
     render: function() {
       switch(this.state.step) {
         case "finished":
-          return <FeedbackReceived />;
+          return (
+            <FeedbackReceived
+              onAfterFeedbackReceived={this.props.onAfterFeedbackReceived} />
+          );
         case "form":
           return <FeedbackForm feedbackApiClient={this.props.feedbackApiClient}
                                sendFeedback={this.sendFeedback}
                                reset={this.reset}
                                pending={this.state.pending} />;
         default:
           return (
             <FeedbackLayout title={
--- a/browser/components/loop/standalone/Makefile
+++ b/browser/components/loop/standalone/Makefile
@@ -8,16 +8,19 @@
 
 # XXX In the interest of making the build logic simpler and
 # more maintainable, we should be trying to implement new
 # functionality in Gruntfile.js rather than here.
 # Bug 1066176 tracks moving all functionality currently here
 # to the Gruntfile and getting rid of this Makefile entirely.
 
 LOOP_SERVER_URL := $(shell echo $${LOOP_SERVER_URL-http://localhost:5000})
+LOOP_FEEDBACK_API_URL := $(shell echo $${LOOP_FEEDBACK_API_URL-"https://input.allizom.org/api/v1/feedback"})
+LOOP_FEEDBACK_PRODUCT_NAME := $(shell echo $${LOOP_FEEDBACK_PRODUCT_NAME-Loop})
+
 NODE_LOCAL_BIN=./node_modules/.bin
 
 install: npm_install tos
 
 npm_install:
 	@npm install
 
 test:
@@ -62,9 +65,11 @@ remove_old_config:
 
 # The services development deployment, however, still wants a static config
 # file, and needs an easy way to generate one.  This target is for folks
 # working with that deployment.
 .PHONY: config
 config:
 	@echo "var loop = loop || {};" > content/config.js
 	@echo "loop.config = loop.config || {};" >> content/config.js
-	@echo "loop.config.serverUrl          = '`echo $(LOOP_SERVER_URL)`';" >> content/config.js
+	@echo "loop.config.serverUrl = '`echo $(LOOP_SERVER_URL)`';" >> content/config.js
+	@echo "loop.config.feedbackApiUrl = '`echo $(LOOP_FEEDBACK_API_URL)`';" >> content/config.js
+	@echo "loop.config.feedbackProductName = '`echo $(LOOP_FEEDBACK_PRODUCT_NAME)`';" >> content/config.js
--- a/browser/components/loop/standalone/README.md
+++ b/browser/components/loop/standalone/README.md
@@ -24,18 +24,22 @@ folks deploying the development server w
 
     $ make config
 
 It will read the configuration from the following env variables and generate the
 appropriate configuration file:
 
 - `LOOP_SERVER_URL` defines the root url of the loop server, without trailing
   slash (default: `http://localhost:5000`).
-- `LOOP_PENDING_CALL_TIMEOUT` defines the amount of time a pending outgoing call
-  should be considered timed out, in milliseconds (default: `20000`).
+- `LOOP_FEEDBACK_API_URL` sets the root URL for the
+  [input API](https://input.mozilla.org/); defaults to the input stage server
+  (https://input.allizom.org/api/v1/feedback). **Don't forget to set this
+  value to the production server URL when deploying to production.**
+- `LOOP_FEEDBACK_PRODUCT_NAME` defines the product name to be sent to the input
+  API (defaults: Loop).
 
 Usage
 -----
 
 For development, run a local static file server:
 
     $ make runserver
 
--- a/browser/components/loop/standalone/content/css/webapp.css
+++ b/browser/components/loop/standalone/content/css/webapp.css
@@ -202,8 +202,46 @@ body,
  * Left / Right padding elements
  * used to center components
  * */
 .flex-padding-1 {
   display: flex;
   flex: 1;
 }
 
+/**
+ * Feedback form overlay (standalone only)
+ */
+.standalone .ended-conversation {
+  position: relative;
+  height: 100%;
+  background-color: #444;
+  text-align: left; /* as backup */
+  text-align: start;
+}
+
+.standalone .ended-conversation .feedback {
+  position: absolute;
+  width: 50%;
+  max-width: 400px;
+  margin: 10px auto;
+  top: 20px;
+  left: 10%;
+  right: 10%;
+  background: #FFF;
+  box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.4);
+  border-radius: 3px;
+  z-index: 1002; /* ensures the form is always on top of the control bar */
+}
+
+.standalone .ended-conversation .local-stream {
+  /* Hide  local media stream when feedback form is shown. */
+  display: none;
+}
+
+@media screen and (max-width:640px) {
+  .standalone .ended-conversation .feedback {
+    width: 92%;
+    top: 10%;
+    left: 5px;
+    right: 5px;
+  }
+}
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -36,16 +36,17 @@
     <script type="text/javascript" src="shared/libs/backbone-1.1.2.js"></script>
 
     <!-- app scripts -->
     <script type="text/javascript" src="config.js"></script>
     <script type="text/javascript" src="shared/js/utils.js"></script>
     <script type="text/javascript" src="shared/js/models.js"></script>
     <script type="text/javascript" src="shared/js/mixins.js"></script>
     <script type="text/javascript" src="shared/js/views.js"></script>
+    <script type="text/javascript" src="shared/js/feedbackApiClient.js"></script>
     <script type="text/javascript" src="shared/js/websocket.js"></script>
     <script type="text/javascript" src="js/standaloneClient.js"></script>
     <script type="text/javascript" src="js/webapp.js"></script>
 
     <script>
       // Wait for all the localization notes to load
       window.addEventListener('localized', function() {
         loop.webapp.init();
--- a/browser/components/loop/standalone/content/js/standaloneClient.js
+++ b/browser/components/loop/standalone/content/js/standaloneClient.js
@@ -117,17 +117,17 @@ loop.StandaloneClient = (function($) {
         dataType:    "json",
         data: JSON.stringify({callType: callType})
       });
 
       req.done(function(sessionData) {
         try {
           cb(null, this._validate(sessionData, expectedCallsProperties));
         } catch (err) {
-          console.log("Error requesting call info", err);
+          console.error("Error requesting call info", err.message);
           cb(err);
         }
       }.bind(this));
 
       req.fail(this._failureHandler.bind(this, cb));
     },
   };
 
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -1,41 +1,35 @@
 /** @jsx React.DOM */
 
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true, React */
-/* jshint newcap:false */
+/* jshint newcap:false, maxlen:false */
 
 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;
 
   /**
-   * App router.
-   * @type {loop.webapp.WebappRouter}
-   */
-  var router;
-
-  /**
    * Homepage view.
    */
   var HomeView = React.createClass({displayName: 'HomeView',
     render: function() {
       return (
         React.DOM.p(null, mozL10n.get("welcome"))
-      )
+      );
     }
   });
 
   /**
    * Unsupported Browsers view.
    */
   var UnsupportedBrowserView = React.createClass({displayName: 'UnsupportedBrowserView',
     render: function() {
@@ -99,28 +93,26 @@ loop.webapp = (function($, _, OT, mozL10
    * Expired call URL view.
    */
   var CallUrlExpiredView = React.createClass({displayName: 'CallUrlExpiredView',
     propTypes: {
       helper: React.PropTypes.object.isRequired
     },
 
     render: function() {
-      /* jshint ignore:start */
       return (
         React.DOM.div({className: "expired-url-info"}, 
           React.DOM.div({className: "info-panel"}, 
             React.DOM.div({className: "firefox-logo"}), 
             React.DOM.h1(null, mozL10n.get("call_url_unavailable_notification_heading")), 
             React.DOM.h4(null, mozL10n.get("call_url_unavailable_notification_message2"))
           ), 
           PromoteFirefoxView({helper: this.props.helper})
         )
       );
-      /* jshint ignore:end */
     }
   });
 
   var ConversationBranding = React.createClass({displayName: 'ConversationBranding',
     render: function() {
       return (
         React.DOM.h1({className: "standalone-header-title"}, 
           React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
@@ -141,28 +133,26 @@ loop.webapp = (function($, _, OT, mozL10
         "hide": !this.props.urlCreationDateString.length
       });
 
       var callUrlCreationDateString = mozL10n.get("call_url_creation_date_label", {
         "call_url_creation_date": this.props.urlCreationDateString
       });
 
       return (
-        /* jshint ignore:start */
         React.DOM.header({className: "standalone-header header-box container-box"}, 
           ConversationBranding(null), 
           React.DOM.div({className: "loop-logo", title: "Firefox WebRTC! logo"}), 
           React.DOM.h3({className: "call-url"}, 
             conversationUrl
           ), 
           React.DOM.h4({className: urlCreationDateClasses}, 
             callUrlCreationDateString
           )
         )
-        /* jshint ignore:end */
       );
     }
   });
 
   var ConversationFooter = React.createClass({displayName: 'ConversationFooter',
     render: function() {
       return (
         React.DOM.div({className: "standalone-footer container-box"}, 
@@ -171,17 +161,17 @@ loop.webapp = (function($, _, OT, mozL10
       );
     }
   });
 
   var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
     getInitialState: function() {
       return {
         callState: this.props.callState || "connecting"
-      }
+      };
     },
 
     propTypes: {
       websocket: React.PropTypes.instanceOf(loop.CallConnectionWebSocket)
                       .isRequired
     },
 
     componentDidMount: function() {
@@ -195,17 +185,16 @@ loop.webapp = (function($, _, OT, mozL10
 
     _cancelOutgoingCall: function() {
       this.props.websocket.cancel();
     },
 
     render: function() {
       var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
       return (
-        /* jshint ignore:start */
         React.DOM.div({className: "container"}, 
           React.DOM.div({className: "container-box"}, 
             React.DOM.header({className: "pending-header header-box"}, 
               ConversationBranding(null)
             ), 
 
             React.DOM.div({id: "cameraPreview"}), 
 
@@ -224,55 +213,49 @@ loop.webapp = (function($, _, OT, mozL10
                 )
               ), 
               React.DOM.div({className: "flex-padding-1"})
             )
           ), 
 
           ConversationFooter(null)
         )
-        /* jshint ignore:end */
       );
     }
   });
 
   /**
    * Conversation launcher view. A ConversationModel is associated and attached
    * as a `model` property.
+   *
+   * Required properties:
+   * - {loop.shared.models.ConversationModel}    model    Conversation model.
+   * - {loop.shared.models.NotificationCollection} notifications
    */
   var StartConversationView = React.createClass({displayName: 'StartConversationView',
-    /**
-     * Constructor.
-     *
-     * Required options:
-     * - {loop.shared.models.ConversationModel}    model    Conversation model.
-     * - {loop.shared.models.NotificationCollection} notifications
-     *
-     */
+    propTypes: {
+      model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                                       .isRequired,
+      // XXX Check more tightly here when we start injecting window.loop.*
+      notifications: React.PropTypes.object.isRequired,
+      client: React.PropTypes.object.isRequired
+    },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {showCallOptionsMenu: false};
     },
 
     getInitialState: function() {
       return {
         urlCreationDateString: '',
         disableCallButton: false,
         showCallOptionsMenu: this.props.showCallOptionsMenu
       };
     },
 
-    propTypes: {
-      model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
-                                       .isRequired,
-      // XXX Check more tightly here when we start injecting window.loop.*
-      notifications: React.PropTypes.object.isRequired,
-      client: React.PropTypes.object.isRequired
-    },
-
     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.client.requestCallUrlInfo(this.props.model.get("loopToken"),
                                            this._setConversationTimestamp);
     },
@@ -343,17 +326,16 @@ loop.webapp = (function($, _, OT, mozL10
         "visually-hidden": !this.state.showCallOptionsMenu
       });
       var tosClasses = React.addons.classSet({
         "terms-service": true,
         hide: (localStorage.getItem("has-seen-tos") === "true")
       });
 
       return (
-        /* jshint ignore:start */
         React.DOM.div({className: "container"}, 
           React.DOM.div({className: "container-box"}, 
 
             ConversationHeader({
               urlCreationDateString: this.state.urlCreationDateString}), 
 
             React.DOM.p({className: "standalone-btn-label"}, 
               mozL10n.get("initiate_call_button_label2")
@@ -402,17 +384,47 @@ loop.webapp = (function($, _, OT, mozL10
             ), 
 
             React.DOM.p({className: tosClasses, 
                dangerouslySetInnerHTML: {__html: tosHTML}})
           ), 
 
           ConversationFooter(null)
         )
-        /* jshint ignore:end */
+      );
+    }
+  });
+
+  /**
+   * Ended conversation view.
+   */
+  var EndedConversationView = React.createClass({displayName: 'EndedConversationView',
+    propTypes: {
+      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                         .isRequired,
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired,
+      onAfterFeedbackReceived: React.PropTypes.func.isRequired
+    },
+
+    render: function() {
+      return (
+        React.DOM.div({className: "ended-conversation"}, 
+          sharedViews.FeedbackView({
+            feedbackApiClient: this.props.feedbackApiClient, 
+            onAfterFeedbackReceived: this.props.onAfterFeedbackReceived}
+          ), 
+          sharedViews.ConversationView({
+            initiate: false, 
+            sdk: this.props.sdk, 
+            model: this.props.conversation, 
+            audio: {enabled: false, visible: false}, 
+            video: {enabled: false, visible: false}}
+          )
+        )
       );
     }
   });
 
   /**
    * This view manages the outgoing conversation views - from
    * call initiation through to the actual conversation and call end.
    *
@@ -421,17 +433,18 @@ loop.webapp = (function($, _, OT, mozL10
   var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
-      sdk: React.PropTypes.object.isRequired
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
         callStatus: "start"
       };
     },
 
@@ -445,61 +458,82 @@ loop.webapp = (function($, _, OT, mozL10
       this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this);
       this.props.conversation.on("session:connection-error", this._notifyError, this);
     },
 
     componentDidUnmount: function() {
       this.props.conversation.off(null, null, this);
     },
 
+    shouldComponentUpdate: function(nextProps, nextState) {
+      // Only rerender if current state has actually changed
+      return nextState.callStatus !== this.state.callStatus;
+    },
+
+    callStatusSwitcher: function(status) {
+      return function() {
+        this.setState({callStatus: status});
+      }.bind(this);
+    },
+
     /**
      * Renders the conversation views.
      */
     render: function() {
       switch (this.state.callStatus) {
         case "failure":
-        case "end":
         case "start": {
           return (
             StartConversationView({
               model: this.props.conversation, 
               notifications: this.props.notifications, 
               client: this.props.client}
             )
           );
         }
         case "pending": {
           return PendingConversationView({websocket: this._websocket});
         }
         case "connected": {
           return (
             sharedViews.ConversationView({
+              initiate: true, 
               sdk: this.props.sdk, 
               model: this.props.conversation, 
               video: {enabled: this.props.conversation.hasVideoStream("outgoing")}}
             )
           );
         }
+        case "end": {
+          return (
+            EndedConversationView({
+              sdk: this.props.sdk, 
+              conversation: this.props.conversation, 
+              feedbackApiClient: this.props.feedbackApiClient, 
+              onAfterFeedbackReceived: this.callStatusSwitcher("start")}
+            )
+          );
+        }
         case "expired": {
           return (
             CallUrlExpiredView({helper: this.props.helper})
           );
         }
         default: {
-          return HomeView(null)
+          return HomeView(null);
         }
       }
     },
 
     /**
      * Notify the user that the connection was not possible
      * @param {{code: number, message: string}} error
      */
     _notifyError: function(error) {
-      console.log(error);
+      console.error(error);
       this.props.notifications.errorL10n("connection_error_see_console_notification");
       this.setState({callStatus: "end"});
     },
 
     /**
      * Peer hung up. Notifies the user and ends the call.
      *
      * Event properties:
@@ -623,23 +657,25 @@ loop.webapp = (function($, _, OT, mozL10
     },
 
     /**
      * Handles call rejection.
      *
      * @param {String} reason The reason the call was terminated.
      */
     _handleCallTerminated: function(reason) {
-      this.setState({callStatus: "end"});
-      // For reasons other than cancel, display some notification text.
       if (reason !== "cancel") {
         // XXX This should really display the call failed view - bug 1046959
         // will implement this.
         this.props.notifications.errorL10n("call_timeout_notification_text");
       }
+      // 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"});
     },
 
     /**
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
       this.setState({callStatus: "end"});
     },
@@ -652,17 +688,18 @@ loop.webapp = (function($, _, OT, mozL10
   var WebappRootView = React.createClass({displayName: 'WebappRootView',
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
-      sdk: React.PropTypes.object.isRequired
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
         unsupportedDevice: this.props.helper.isIOS(navigator.platform),
         unsupportedBrowser: !this.props.sdk.checkSystemRequirements(),
       };
     },
@@ -674,17 +711,18 @@ loop.webapp = (function($, _, OT, mozL10
         return UnsupportedBrowserView(null);
       } else if (this.props.conversation.get("loopToken")) {
         return (
           OutgoingConversationView({
              client: this.props.client, 
              conversation: this.props.conversation, 
              helper: this.props.helper, 
              notifications: this.props.notifications, 
-             sdk: this.props.sdk}
+             sdk: this.props.sdk, 
+             feedbackApiClient: this.props.feedbackApiClient}
           )
         );
       } else {
         return HomeView(null);
       }
     }
   });
 
@@ -716,41 +754,49 @@ loop.webapp = (function($, _, OT, mozL10
     var helper = new WebappHelper();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
     var notifications = new sharedModels.NotificationCollection();
     var conversation = new sharedModels.ConversationModel({}, {
       sdk: OT
     });
+    var feedbackApiClient = new loop.FeedbackAPIClient(
+      loop.config.feedbackApiUrl, {
+        product: loop.config.feedbackProductName,
+        user_agent: navigator.userAgent,
+        url: document.location.origin
+      });
 
     // Obtain the loopToken and pass it to the conversation
     var locationHash = helper.locationHash();
     if (locationHash) {
       conversation.set("loopToken", locationHash.match(/\#call\/(.*)/)[1]);
     }
 
     React.renderComponent(WebappRootView({
       client: client, 
       conversation: conversation, 
       helper: helper, 
       notifications: notifications, 
-      sdk: OT}
+      sdk: OT, 
+      feedbackApiClient: feedbackApiClient}
     ), document.querySelector("#main"));
 
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
     OutgoingConversationView: OutgoingConversationView,
+    EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
     WebappHelper: WebappHelper,
     WebappRootView: WebappRootView
   };
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -1,41 +1,35 @@
 /** @jsx React.DOM */
 
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true, React */
-/* jshint newcap:false */
+/* jshint newcap:false, maxlen:false */
 
 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;
 
   /**
-   * App router.
-   * @type {loop.webapp.WebappRouter}
-   */
-  var router;
-
-  /**
    * Homepage view.
    */
   var HomeView = React.createClass({
     render: function() {
       return (
         <p>{mozL10n.get("welcome")}</p>
-      )
+      );
     }
   });
 
   /**
    * Unsupported Browsers view.
    */
   var UnsupportedBrowserView = React.createClass({
     render: function() {
@@ -99,28 +93,26 @@ loop.webapp = (function($, _, OT, mozL10
    * Expired call URL view.
    */
   var CallUrlExpiredView = React.createClass({
     propTypes: {
       helper: React.PropTypes.object.isRequired
     },
 
     render: function() {
-      /* jshint ignore:start */
       return (
         <div className="expired-url-info">
           <div className="info-panel">
             <div className="firefox-logo" />
             <h1>{mozL10n.get("call_url_unavailable_notification_heading")}</h1>
             <h4>{mozL10n.get("call_url_unavailable_notification_message2")}</h4>
           </div>
           <PromoteFirefoxView helper={this.props.helper} />
         </div>
       );
-      /* jshint ignore:end */
     }
   });
 
   var ConversationBranding = React.createClass({
     render: function() {
       return (
         <h1 className="standalone-header-title">
           <strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
@@ -141,28 +133,26 @@ loop.webapp = (function($, _, OT, mozL10
         "hide": !this.props.urlCreationDateString.length
       });
 
       var callUrlCreationDateString = mozL10n.get("call_url_creation_date_label", {
         "call_url_creation_date": this.props.urlCreationDateString
       });
 
       return (
-        /* jshint ignore:start */
         <header className="standalone-header header-box container-box">
           <ConversationBranding />
           <div className="loop-logo" title="Firefox WebRTC! logo"></div>
           <h3 className="call-url">
             {conversationUrl}
           </h3>
           <h4 className={urlCreationDateClasses} >
             {callUrlCreationDateString}
           </h4>
         </header>
-        /* jshint ignore:end */
       );
     }
   });
 
   var ConversationFooter = React.createClass({
     render: function() {
       return (
         <div className="standalone-footer container-box">
@@ -171,17 +161,17 @@ loop.webapp = (function($, _, OT, mozL10
       );
     }
   });
 
   var PendingConversationView = React.createClass({
     getInitialState: function() {
       return {
         callState: this.props.callState || "connecting"
-      }
+      };
     },
 
     propTypes: {
       websocket: React.PropTypes.instanceOf(loop.CallConnectionWebSocket)
                       .isRequired
     },
 
     componentDidMount: function() {
@@ -195,17 +185,16 @@ loop.webapp = (function($, _, OT, mozL10
 
     _cancelOutgoingCall: function() {
       this.props.websocket.cancel();
     },
 
     render: function() {
       var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
       return (
-        /* jshint ignore:start */
         <div className="container">
           <div className="container-box">
             <header className="pending-header header-box">
               <ConversationBranding />
             </header>
 
             <div id="cameraPreview"></div>
 
@@ -224,55 +213,49 @@ loop.webapp = (function($, _, OT, mozL10
                 </span>
               </button>
               <div className="flex-padding-1"></div>
             </div>
           </div>
 
           <ConversationFooter />
         </div>
-        /* jshint ignore:end */
       );
     }
   });
 
   /**
    * Conversation launcher view. A ConversationModel is associated and attached
    * as a `model` property.
+   *
+   * Required properties:
+   * - {loop.shared.models.ConversationModel}    model    Conversation model.
+   * - {loop.shared.models.NotificationCollection} notifications
    */
   var StartConversationView = React.createClass({
-    /**
-     * Constructor.
-     *
-     * Required options:
-     * - {loop.shared.models.ConversationModel}    model    Conversation model.
-     * - {loop.shared.models.NotificationCollection} notifications
-     *
-     */
+    propTypes: {
+      model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                                       .isRequired,
+      // XXX Check more tightly here when we start injecting window.loop.*
+      notifications: React.PropTypes.object.isRequired,
+      client: React.PropTypes.object.isRequired
+    },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {showCallOptionsMenu: false};
     },
 
     getInitialState: function() {
       return {
         urlCreationDateString: '',
         disableCallButton: false,
         showCallOptionsMenu: this.props.showCallOptionsMenu
       };
     },
 
-    propTypes: {
-      model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
-                                       .isRequired,
-      // XXX Check more tightly here when we start injecting window.loop.*
-      notifications: React.PropTypes.object.isRequired,
-      client: React.PropTypes.object.isRequired
-    },
-
     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.client.requestCallUrlInfo(this.props.model.get("loopToken"),
                                            this._setConversationTimestamp);
     },
@@ -343,17 +326,16 @@ loop.webapp = (function($, _, OT, mozL10
         "visually-hidden": !this.state.showCallOptionsMenu
       });
       var tosClasses = React.addons.classSet({
         "terms-service": true,
         hide: (localStorage.getItem("has-seen-tos") === "true")
       });
 
       return (
-        /* jshint ignore:start */
         <div className="container">
           <div className="container-box">
 
             <ConversationHeader
               urlCreationDateString={this.state.urlCreationDateString} />
 
             <p className="standalone-btn-label">
               {mozL10n.get("initiate_call_button_label2")}
@@ -402,17 +384,47 @@ loop.webapp = (function($, _, OT, mozL10
             </div>
 
             <p className={tosClasses}
                dangerouslySetInnerHTML={{__html: tosHTML}}></p>
           </div>
 
           <ConversationFooter />
         </div>
-        /* jshint ignore:end */
+      );
+    }
+  });
+
+  /**
+   * Ended conversation view.
+   */
+  var EndedConversationView = React.createClass({
+    propTypes: {
+      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                         .isRequired,
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired,
+      onAfterFeedbackReceived: React.PropTypes.func.isRequired
+    },
+
+    render: function() {
+      return (
+        <div className="ended-conversation">
+          <sharedViews.FeedbackView
+            feedbackApiClient={this.props.feedbackApiClient}
+            onAfterFeedbackReceived={this.props.onAfterFeedbackReceived}
+          />
+          <sharedViews.ConversationView
+            initiate={false}
+            sdk={this.props.sdk}
+            model={this.props.conversation}
+            audio={{enabled: false, visible: false}}
+            video={{enabled: false, visible: false}}
+          />
+        </div>
       );
     }
   });
 
   /**
    * This view manages the outgoing conversation views - from
    * call initiation through to the actual conversation and call end.
    *
@@ -421,17 +433,18 @@ loop.webapp = (function($, _, OT, mozL10
   var OutgoingConversationView = React.createClass({
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
-      sdk: React.PropTypes.object.isRequired
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
         callStatus: "start"
       };
     },
 
@@ -445,61 +458,82 @@ loop.webapp = (function($, _, OT, mozL10
       this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this);
       this.props.conversation.on("session:connection-error", this._notifyError, this);
     },
 
     componentDidUnmount: function() {
       this.props.conversation.off(null, null, this);
     },
 
+    shouldComponentUpdate: function(nextProps, nextState) {
+      // Only rerender if current state has actually changed
+      return nextState.callStatus !== this.state.callStatus;
+    },
+
+    callStatusSwitcher: function(status) {
+      return function() {
+        this.setState({callStatus: status});
+      }.bind(this);
+    },
+
     /**
      * Renders the conversation views.
      */
     render: function() {
       switch (this.state.callStatus) {
         case "failure":
-        case "end":
         case "start": {
           return (
             <StartConversationView
               model={this.props.conversation}
               notifications={this.props.notifications}
               client={this.props.client}
             />
           );
         }
         case "pending": {
           return <PendingConversationView websocket={this._websocket} />;
         }
         case "connected": {
           return (
             <sharedViews.ConversationView
+              initiate={true}
               sdk={this.props.sdk}
               model={this.props.conversation}
               video={{enabled: this.props.conversation.hasVideoStream("outgoing")}}
             />
           );
         }
+        case "end": {
+          return (
+            <EndedConversationView
+              sdk={this.props.sdk}
+              conversation={this.props.conversation}
+              feedbackApiClient={this.props.feedbackApiClient}
+              onAfterFeedbackReceived={this.callStatusSwitcher("start")}
+            />
+          );
+        }
         case "expired": {
           return (
             <CallUrlExpiredView helper={this.props.helper} />
           );
         }
         default: {
-          return <HomeView />
+          return <HomeView />;
         }
       }
     },
 
     /**
      * Notify the user that the connection was not possible
      * @param {{code: number, message: string}} error
      */
     _notifyError: function(error) {
-      console.log(error);
+      console.error(error);
       this.props.notifications.errorL10n("connection_error_see_console_notification");
       this.setState({callStatus: "end"});
     },
 
     /**
      * Peer hung up. Notifies the user and ends the call.
      *
      * Event properties:
@@ -623,23 +657,25 @@ loop.webapp = (function($, _, OT, mozL10
     },
 
     /**
      * Handles call rejection.
      *
      * @param {String} reason The reason the call was terminated.
      */
     _handleCallTerminated: function(reason) {
-      this.setState({callStatus: "end"});
-      // For reasons other than cancel, display some notification text.
       if (reason !== "cancel") {
         // XXX This should really display the call failed view - bug 1046959
         // will implement this.
         this.props.notifications.errorL10n("call_timeout_notification_text");
       }
+      // 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"});
     },
 
     /**
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
       this.setState({callStatus: "end"});
     },
@@ -652,17 +688,18 @@ loop.webapp = (function($, _, OT, mozL10
   var WebappRootView = React.createClass({
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
-      sdk: React.PropTypes.object.isRequired
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
         unsupportedDevice: this.props.helper.isIOS(navigator.platform),
         unsupportedBrowser: !this.props.sdk.checkSystemRequirements(),
       };
     },
@@ -675,16 +712,17 @@ loop.webapp = (function($, _, OT, mozL10
       } else if (this.props.conversation.get("loopToken")) {
         return (
           <OutgoingConversationView
              client={this.props.client}
              conversation={this.props.conversation}
              helper={this.props.helper}
              notifications={this.props.notifications}
              sdk={this.props.sdk}
+             feedbackApiClient={this.props.feedbackApiClient}
           />
         );
       } else {
         return <HomeView />;
       }
     }
   });
 
@@ -716,41 +754,49 @@ loop.webapp = (function($, _, OT, mozL10
     var helper = new WebappHelper();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
     var notifications = new sharedModels.NotificationCollection();
     var conversation = new sharedModels.ConversationModel({}, {
       sdk: OT
     });
+    var feedbackApiClient = new loop.FeedbackAPIClient(
+      loop.config.feedbackApiUrl, {
+        product: loop.config.feedbackProductName,
+        user_agent: navigator.userAgent,
+        url: document.location.origin
+      });
 
     // Obtain the loopToken and pass it to the conversation
     var locationHash = helper.locationHash();
     if (locationHash) {
       conversation.set("loopToken", locationHash.match(/\#call\/(.*)/)[1]);
     }
 
     React.renderComponent(<WebappRootView
       client={client}
       conversation={conversation}
       helper={helper}
       notifications={notifications}
       sdk={OT}
+      feedbackApiClient={feedbackApiClient}
     />, document.querySelector("#main"));
 
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
     OutgoingConversationView: OutgoingConversationView,
+    EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
     WebappHelper: WebappHelper,
     WebappRootView: WebappRootView
   };
--- a/browser/components/loop/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -41,8 +41,37 @@ legal_text_and_links=By using this produ
 terms_of_use_link_text=Terms of use
 privacy_notice_link_text=Privacy notice
 brandShortname=Firefox
 clientShortname=WebRTC!
 ## LOCALIZATION NOTE (call_url_creation_date_label): Example output: (from May 26, 2014)
 call_url_creation_date_label=(from {{call_url_creation_date}})
 call_progress_connecting_description=Connecting…
 call_progress_ringing_description=Ringing…
+
+feedback_call_experience_heading2=How was your conversation?
+feedback_what_makes_you_sad=What makes you sad?
+feedback_thank_you_heading=Thank you for your feedback!
+feedback_category_audio_quality=Audio quality
+feedback_category_video_quality=Video quality
+feedback_category_was_disconnected=Was disconnected
+feedback_category_confusing=Confusing
+feedback_category_other=Other:
+feedback_custom_category_text_placeholder=What went wrong?
+feedback_submit_button=Submit
+feedback_back_button=Back
+## LOCALIZATION NOTE (feedback_window_will_close_in2):
+## Gaia l10n format; see https://github.com/mozilla-b2g/gaia/blob/f108c706fae43cd61628babdd9463e7695b2496e/apps/email/locales/email.en-US.properties#L387
+## In this item, don't translate the part between {{..}}
+feedback_window_will_close_in2={[ plural(countdown) ]}
+feedback_window_will_close_in2[one] = This window will close in {{countdown}} second
+feedback_window_will_close_in2[two] = This window will close in {{countdown}} seconds
+feedback_window_will_close_in2[few] = This window will close in {{countdown}} seconds
+feedback_window_will_close_in2[many] = This window will close in {{countdown}} seconds
+feedback_window_will_close_in2[other] = This window will close in {{countdown}} seconds
+
+## LOCALIZATION_NOTE (feedback_rejoin_button): Displayed on the feedback form after
+## a signed-in to signed-in user call.
+## https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#feedback
+feedback_rejoin_button=Rejoin
+## LOCALIZATION NOTE (feedback_report_user_button): Used to report a user in the case of
+## an abusive user.
+feedback_report_user_button=Report User
--- a/browser/components/loop/standalone/server.js
+++ b/browser/components/loop/standalone/server.js
@@ -2,27 +2,31 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 var express = require('express');
 var app = express();
 
 var port = process.env.PORT || 3000;
 var loopServerPort = process.env.LOOP_SERVER_PORT || 5000;
+var feedbackApiUrl = process.env.LOOP_FEEDBACK_API_URL ||
+                     "https://input.allizom.org/api/v1/feedback";
+var feedbackProductName = process.env.LOOP_FEEDBACK_PRODUCT_NAME || "Loop";
 
 function getConfigFile(req, res) {
   "use strict";
 
   res.set('Content-Type', 'text/javascript');
-  res.send(
-    "var loop = loop || {};" +
-    "loop.config = loop.config || {};" +
-    "loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';" +
-    "loop.config.pendingCallTimeout = 20000;"
-  );
+  res.send([
+    "var loop = loop || {};",
+    "loop.config = loop.config || {};",
+    "loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';",
+    "loop.config.feedbackApiUrl = '" + feedbackApiUrl + "';",
+    "loop.config.feedbackProductName = '" + feedbackProductName + "';",
+  ].join("\n"));
 }
 
 app.get('/content/config.js', getConfigFile);
 
 // This lets /test/ be mapped to the right place for running tests
 app.use('/', express.static(__dirname + '/../'));
 
 // Magic so that the legal content works both in the standalone server
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -103,18 +103,17 @@ describe("loop.conversation", function()
   });
 
   describe("ConversationRouter", function() {
     var conversation, client;
 
     beforeEach(function() {
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
-        sdk: {},
-        pendingCallTimeout: 1000,
+        sdk: {}
       });
       sandbox.spy(conversation, "setIncomingSessionData");
       sandbox.stub(conversation, "setOutgoingSessionData");
     });
 
     describe("Routes", function() {
       var router;
 
--- a/browser/components/loop/test/shared/feedbackApiClient_test.js
+++ b/browser/components/loop/test/shared/feedbackApiClient_test.js
@@ -133,16 +133,23 @@ describe("loop.FeedbackAPIClient", funct
 
       it("should send user_agent information when provided", function() {
         client.send({user_agent: "MOZAGENT"}, function(){});
 
         var parsed = JSON.parse(requests[0].requestBody);
         expect(parsed.user_agent).eql("MOZAGENT");
       });
 
+      it("should send url information when provided", function() {
+        client.send({url: "http://fake.invalid"}, function(){});
+
+        var parsed = JSON.parse(requests[0].requestBody);
+        expect(parsed.url).eql("http://fake.invalid");
+      });
+
       it("should throw on invalid feedback data", function() {
         expect(function() {
           client.send("invalid data", function(){});
         }).to.Throw(/Invalid/);
       });
 
       it("should throw on unsupported field name", function() {
         expect(function() {
--- a/browser/components/loop/test/shared/router_test.js
+++ b/browser/components/loop/test/shared/router_test.js
@@ -55,18 +55,17 @@ describe("loop.shared.router", function(
 
     beforeEach(function() {
       TestRouter = loop.shared.router.BaseConversationRouter.extend({
         endCall: sandbox.spy()
       });
       conversation = new loop.shared.models.ConversationModel({
         loopToken: "fakeToken"
       }, {
-        sdk: {},
-        pendingCallTimeout: 1000
+        sdk: {}
       });
     });
 
     describe("#constructor", function() {
       it("should require a ConversationModel instance", function() {
         expect(function() {
           new TestRouter({ client: {} });
         }).to.Throw(Error, /missing required conversation/);
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -182,34 +182,46 @@ describe("loop.shared.views", function()
         publishAudio: sandbox.spy(),
         publishVideo: sandbox.spy()
       }, Backbone.Events);
       fakeSDK = {
         initPublisher: sandbox.stub().returns(fakePublisher),
         initSession: sandbox.stub().returns(fakeSession)
       };
       model = new sharedModels.ConversationModel(fakeSessionData, {
-        sdk: fakeSDK,
-        pendingCallTimeout: 1000
+        sdk: fakeSDK
       });
     });
 
     describe("#componentDidMount", function() {
-      it("should start a session", function() {
+      it("should start a session by default", function() {
         sandbox.stub(model, "startSession");
 
         mountTestComponent({
           sdk: fakeSDK,
           model: model,
           video: {enabled: true}
         });
 
         sinon.assert.calledOnce(model.startSession);
       });
 
+      it("shouldn't start a session if initiate is false", function() {
+        sandbox.stub(model, "startSession");
+
+        mountTestComponent({
+          initiate: false,
+          sdk: fakeSDK,
+          model: model,
+          video: {enabled: true}
+        });
+
+        sinon.assert.notCalled(model.startSession);
+      });
+
       it("should set the correct stream publish options", function() {
 
         var component = mountTestComponent({
           sdk: fakeSDK,
           model: model,
           video: {enabled: false}
         });
 
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -31,16 +31,17 @@
     mocha.setup('bdd');
   </script>
   <!-- App scripts -->
   <script src="../../content/shared/js/utils.js"></script>
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
+  <script src="../../content/shared/js/feedbackApiClient.js"></script>
   <script src="../../standalone/content/js/standaloneClient.js"></script>
   <script src="../../standalone/content/js/webapp.js"></script>
  <!-- Test scripts -->
   <script src="standalone_client_test.js"></script>
   <script src="webapp_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -8,34 +8,39 @@ var expect = chai.expect;
 var TestUtils = React.addons.TestUtils;
 
 describe("loop.webapp", function() {
   "use strict";
 
   var sharedModels = loop.shared.models,
       sharedViews = loop.shared.views,
       sandbox,
-      notifications;
+      notifications,
+      feedbackApiClient;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     notifications = new sharedModels.NotificationCollection();
+    feedbackApiClient = new loop.FeedbackAPIClient("http://invalid", {
+      product: "Loop"
+    });
   });
 
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("#init", function() {
     var conversationSetStub;
 
     beforeEach(function() {
       sandbox.stub(React, "renderComponent");
       sandbox.stub(loop.webapp.WebappHelper.prototype,
                    "locationHash").returns("#call/fake-Token");
+      loop.config.feedbackApiUrl = "http://fake.invalid";
       conversationSetStub =
         sandbox.stub(sharedModels.ConversationModel.prototype, "set");
     });
 
     it("should create the WebappRootView", function() {
       loop.webapp.init();
 
       sinon.assert.calledOnce(React.renderComponent);
@@ -72,17 +77,18 @@ describe("loop.webapp", function() {
         sdk: {}
       });
       conversation.set("loopToken", "fakeToken");
       ocView = mountTestComponent({
         helper: new loop.webapp.WebappHelper(),
         client: client,
         conversation: conversation,
         notifications: notifications,
-        sdk: {}
+        sdk: {},
+        feedbackApiClient: feedbackApiClient
       });
     });
 
     describe("start", function() {
       it("should display the StartConversationView", function() {
         TestUtils.findRenderedComponentWithType(ocView,
           loop.webapp.StartConversationView);
       });
@@ -300,26 +306,26 @@ describe("loop.webapp", function() {
           });
       });
 
       describe("session:ended", function() {
         it("should set display the StartConversationView", function() {
           conversation.trigger("session:ended");
 
           TestUtils.findRenderedComponentWithType(ocView,
-            loop.webapp.StartConversationView);
+            loop.webapp.EndedConversationView);
         });
       });
 
       describe("session:peer-hungup", function() {
         it("should set display the StartConversationView", function() {
           conversation.trigger("session:peer-hungup");
 
           TestUtils.findRenderedComponentWithType(ocView,
-            loop.webapp.StartConversationView);
+            loop.webapp.EndedConversationView);
         });
 
         it("should notify the user", function() {
           conversation.trigger("session:peer-hungup");
 
           sinon.assert.calledOnce(notifications.warnL10n);
           sinon.assert.calledWithExactly(notifications.warnL10n,
                                          "peer_ended_conversation2");
@@ -328,17 +334,17 @@ describe("loop.webapp", function() {
       });
 
       describe("session:network-disconnected", function() {
         it("should display the StartConversationView",
           function() {
             conversation.trigger("session:network-disconnected");
 
             TestUtils.findRenderedComponentWithType(ocView,
-              loop.webapp.StartConversationView);
+              loop.webapp.EndedConversationView);
           });
 
         it("should notify the user", function() {
           conversation.trigger("session:network-disconnected");
 
           sinon.assert.calledOnce(notifications.warnL10n);
           sinon.assert.calledWithExactly(notifications.warnL10n,
                                          "network_disconnected");
@@ -469,18 +475,20 @@ describe("loop.webapp", function() {
   describe("WebappRootView", function() {
     var webappHelper, sdk, conversationModel, client, props;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.webapp.WebappRootView({
         client: client,
         helper: webappHelper,
+        notifications: notifications,
         sdk: sdk,
-        conversation: conversationModel
+        conversation: conversationModel,
+        feedbackApiClient: feedbackApiClient
       }));
     }
 
     beforeEach(function() {
       webappHelper = new loop.webapp.WebappHelper();
       sdk = {
         checkSystemRequirements: function() { return true; }
       };
@@ -767,16 +775,42 @@ describe("loop.webapp", function() {
         );
         tos = view.getDOMNode().querySelector(".terms-service");
 
         expect(tos.classList.contains("hide")).to.equal(true);
       });
     });
   });
 
+  describe("EndedConversationView", function() {
+    var view, conversation;
+
+    beforeEach(function() {
+      conversation = new sharedModels.ConversationModel({}, {
+        sdk: {}
+      });
+      view = React.addons.TestUtils.renderIntoDocument(
+        loop.webapp.EndedConversationView({
+          conversation: conversation,
+          sdk: {},
+          feedbackApiClient: feedbackApiClient,
+          onAfterFeedbackReceived: function(){}
+        })
+      );
+    });
+
+    it("should render a ConversationView", function() {
+      TestUtils.findRenderedComponentWithType(view, sharedViews.ConversationView);
+    });
+
+    it("should render a FeedbackView", function() {
+      TestUtils.findRenderedComponentWithType(view, sharedViews.FeedbackView);
+    });
+  });
+
   describe("PromoteFirefoxView", function() {
     describe("#render", function() {
       it("should not render when using Firefox", function() {
         var comp = TestUtils.renderIntoDocument(loop.webapp.PromoteFirefoxView({
           helper: {isFirefox: function() { return true; }}
         }));
 
         expect(comp.getDOMNode().querySelectorAll("h3").length).eql(0);
--- a/browser/components/loop/ui/fake-l10n.js
+++ b/browser/components/loop/ui/fake-l10n.js
@@ -4,16 +4,20 @@
 
 /**
  * /!\ FIXME: THIS IS A HORRID HACK which fakes both the mozL10n and webL10n
  * objects and makes them returning the string id and serialized vars if any,
  * for any requested string id.
  * @type {Object}
  */
 navigator.mozL10n = document.mozL10n = {
+  initialize: function(){},
+
+  getDirection: function(){},
+
   get: function(stringId, vars) {
 
     // upcase the first letter
     var readableStringId = stringId.replace(/^./, function(match) {
       "use strict";
       return match.toUpperCase();
     }).replace(/_/g, " ");  // and convert _ chars to spaces
 
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -2,11 +2,12 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /**
  * Faking the mozLoop object which doesn't exist in regular web pages.
  * @type {Object}
  */
 navigator.mozLoop = {
+  ensureRegistered: function() {},
   getLoopCharPref: function() {},
   getLoopBoolPref: function() {}
 };
--- a/browser/components/loop/ui/ui-showcase.css
+++ b/browser/components/loop/ui/ui-showcase.css
@@ -32,17 +32,17 @@
 .showcase-menu > a {
   margin-right: .5em;
   padding: .4rem;
   margin-top: .2rem;
 }
 
 .showcase > section {
   position: relative;
-  padding-top: 12em;
+  padding-top: 14em;
   clear: both;
 }
 
 .showcase > section > h1 {
   margin: 1em 0;
   border-bottom: 1px solid #aaa;
 }
 
@@ -144,8 +144,14 @@
   max-width: 120px;
 }
 
 .conversation .media.nested .remote {
   /* Height of obsolute box covers media control buttons. UI showcase only.
    * When tokbox inserts the markup into the page the problem goes away */
   bottom: auto;
 }
+
+.standalone .ended-conversation .remote_wrapper,
+.standalone .video-layout-wrapper {
+  /* Removes the fake video image for ended conversations */
+  background: none;
+}
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -18,16 +18,17 @@
 
   // 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;
+  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() {
@@ -333,16 +334,29 @@
             Example({summary: "Firefox User"}, 
               CallUrlExpiredView({helper: {isFirefox: returnTrue}})
             ), 
             Example({summary: "Non-Firefox User"}, 
               CallUrlExpiredView({helper: {isFirefox: returnFalse}})
             )
           ), 
 
+          Section({name: "EndedConversationView"}, 
+            Example({summary: "Displays the feedback form"}, 
+              React.DOM.div({className: "standalone"}, 
+                EndedConversationView({sdk: mockSDK, 
+                                       video: {enabled: true}, 
+                                       audio: {enabled: true}, 
+                                       conversation: mockConversationModel, 
+                                       feedbackApiClient: stageFeedbackApiClient, 
+                                       onAfterFeedbackReceived: noop})
+              )
+            )
+          ), 
+
           Section({name: "AlertMessages"}, 
             Example({summary: "Various alerts"}, 
               React.DOM.div({className: "alert alert-warning"}, 
                 React.DOM.button({className: "close"}), 
                 React.DOM.p({className: "message"}, 
                   "The person you were calling has ended the conversation."
                 )
               ), 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -18,16 +18,17 @@
 
   // 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;
+  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() {
@@ -333,16 +334,29 @@
             <Example summary="Firefox User">
               <CallUrlExpiredView helper={{isFirefox: returnTrue}} />
             </Example>
             <Example summary="Non-Firefox User">
               <CallUrlExpiredView helper={{isFirefox: returnFalse}} />
             </Example>
           </Section>
 
+          <Section name="EndedConversationView">
+            <Example summary="Displays the feedback form">
+              <div className="standalone">
+                <EndedConversationView sdk={mockSDK}
+                                       video={{enabled: true}}
+                                       audio={{enabled: true}}
+                                       conversation={mockConversationModel}
+                                       feedbackApiClient={stageFeedbackApiClient}
+                                       onAfterFeedbackReceived={noop} />
+              </div>
+            </Example>
+          </Section>
+
           <Section name="AlertMessages">
             <Example summary="Various alerts">
               <div className="alert alert-warning">
                 <button className="close"></button>
                 <p className="message">
                   The person you were calling has ended the conversation.
                 </p>
               </div>