Bug 1076754 - Moved Loop feedback flow to Flux. r=Standard8 a=lsblakk
authorNicolas Perriault <nperriault@mozilla.com>
Mon, 24 Nov 2014 17:02:48 +0100
changeset 234057 20b667fbd257cf2e90b105d748710c7cde4ccaee
parent 234056 c1c67ca8a76ff80a1f194ebc99fca1fb1a4c53d3
child 234058 323dcdb9ff99de3ceff7c5b7ffe283cfd06f40fd
push id4187
push userbhearsum@mozilla.com
push dateFri, 28 Nov 2014 15:29:12 +0000
treeherdermozilla-beta@f23cc6a30c11 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8, lsblakk
bugs1076754
milestone35.0a2
Bug 1076754 - Moved Loop feedback flow to Flux. r=Standard8 a=lsblakk
browser/components/loop/content/conversation.html
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/feedbackApiClient.js
browser/components/loop/content/shared/js/feedbackStore.js
browser/components/loop/content/shared/js/feedbackViews.js
browser/components/loop/content/shared/js/feedbackViews.jsx
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/jar.mn
browser/components/loop/standalone/content/index.html
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/desktop-local/index.html
browser/components/loop/test/shared/feedbackStore_test.js
browser/components/loop/test/shared/feedbackViews_test.js
browser/components/loop/test/shared/index.html
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/index.html
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/conversation.html
+++ b/browser/components/loop/content/conversation.html
@@ -33,16 +33,18 @@
     <script type="text/javascript" src="loop/shared/js/actions.js"></script>
     <script type="text/javascript" src="loop/shared/js/validate.js"></script>
     <script type="text/javascript" src="loop/shared/js/dispatcher.js"></script>
     <script type="text/javascript" src="loop/shared/js/otSdkDriver.js"></script>
     <script type="text/javascript" src="loop/shared/js/store.js"></script>
     <script type="text/javascript" src="loop/shared/js/roomStore.js"></script>
     <script type="text/javascript" src="loop/shared/js/conversationStore.js"></script>
     <script type="text/javascript" src="loop/shared/js/activeRoomStore.js"></script>
+    <script type="text/javascript" src="loop/shared/js/feedbackStore.js"></script>
+    <script type="text/javascript" src="loop/shared/js/feedbackViews.js"></script>
     <script type="text/javascript" src="loop/js/conversationViews.js"></script>
     <script type="text/javascript" src="loop/shared/js/websocket.js"></script>
     <script type="text/javascript" src="loop/js/conversationAppStore.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
     <script type="text/javascript" src="loop/js/conversationViews.js"></script>
     <script type="text/javascript" src="loop/js/roomViews.js"></script>
     <script type="text/javascript" src="loop/js/conversation.js"></script>
   </body>
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -224,17 +224,18 @@ loop.conversation = (function(mozL10n) {
    */
   var IncomingConversationView = React.createClass({displayName: 'IncomingConversationView',
     propTypes: {
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
       conversationAppStore: React.PropTypes.instanceOf(
-        loop.store.ConversationAppStore).isRequired
+        loop.store.ConversationAppStore).isRequired,
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
       return {
         callFailed: false, // XXX this should be removed when bug 1047410 lands.
         callStatus: "start"
       };
     },
@@ -296,31 +297,19 @@ loop.conversation = (function(mozL10n) {
           if (this.state.callFailed) {
             return GenericFailureView({
               cancelCall: this.closeWindow.bind(this)}
             );
           }
 
           document.title = mozL10n.get("conversation_has_ended");
 
-          var feebackAPIBaseUrl = navigator.mozLoop.getLoopPref(
-            "feedback.baseUrl");
-
-          var appVersionInfo = navigator.mozLoop.appVersionInfo;
-
-          var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
-            product: navigator.mozLoop.getLoopPref("feedback.product"),
-            platform: appVersionInfo.OS,
-            channel: appVersionInfo.channel,
-            version: appVersionInfo.version
-          });
-
           return (
             sharedViews.FeedbackView({
-              feedbackApiClient: feedbackClient, 
+              feedbackStore: this.props.feedbackStore, 
               onAfterFeedbackReceived: this.closeWindow.bind(this)}
             )
           );
         }
         case "close": {
           window.close();
           return (React.DOM.div(null));
         }
@@ -557,17 +546,18 @@ loop.conversation = (function(mozL10n) {
       sdk: React.PropTypes.object.isRequired,
 
       // XXX New types for flux style
       conversationAppStore: React.PropTypes.instanceOf(
         loop.store.ConversationAppStore).isRequired,
       conversationStore: React.PropTypes.instanceOf(loop.store.ConversationStore)
                               .isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
-      roomStore: React.PropTypes.instanceOf(loop.store.RoomStore)
+      roomStore: React.PropTypes.instanceOf(loop.store.RoomStore),
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
       return this.props.conversationAppStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.conversationAppStore, "change", function() {
@@ -585,36 +575,36 @@ loop.conversation = (function(mozL10n) {
 
     render: function() {
       switch(this.state.windowType) {
         case "incoming": {
           return (IncomingConversationView({
             client: this.props.client, 
             conversation: this.props.conversation, 
             sdk: this.props.sdk, 
-            conversationAppStore: this.props.conversationAppStore}
+            conversationAppStore: this.props.conversationAppStore, 
+            feedbackStore: this.props.feedbackStore}
           ));
         }
         case "outgoing": {
           return (OutgoingConversationView({
             store: this.props.conversationStore, 
-            dispatcher: this.props.dispatcher}
+            dispatcher: this.props.dispatcher, 
+            feedbackStore: this.props.feedbackStore}
           ));
         }
         case "room": {
           return (DesktopRoomConversationView({
             dispatcher: this.props.dispatcher, 
             roomStore: this.props.roomStore, 
-            dispatcher: this.props.dispatcher}
+            feedbackStore: this.props.feedbackStore}
           ));
         }
         case "failed": {
-          return (GenericFailureView({
-            cancelCall: this.closeWindow}
-          ));
+          return GenericFailureView({cancelCall: this.closeWindow});
         }
         default: {
           // If we don't have a windowType, we don't know what we are yet,
           // so don't display anything.
           return null;
         }
       }
     }
@@ -641,16 +631,24 @@ loop.conversation = (function(mozL10n) {
     });
 
     var dispatcher = new loop.Dispatcher();
     var client = new loop.Client();
     var sdkDriver = new loop.OTSdkDriver({
       dispatcher: dispatcher,
       sdk: OT
     });
+    var appVersionInfo = navigator.mozLoop.appVersionInfo;
+    var feedbackClient = new loop.FeedbackAPIClient(
+      navigator.mozLoop.getLoopPref("feedback.baseUrl"), {
+      product: navigator.mozLoop.getLoopPref("feedback.product"),
+      platform: appVersionInfo.OS,
+      channel: appVersionInfo.channel,
+      version: appVersionInfo.version
+    });
 
     // Create the stores.
     var conversationAppStore = new loop.store.ConversationAppStore({
       dispatcher: dispatcher,
       mozLoop: navigator.mozLoop
     });
     var conversationStore = new loop.store.ConversationStore({}, {
       client: client,
@@ -660,16 +658,19 @@ loop.conversation = (function(mozL10n) {
     var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
       mozLoop: navigator.mozLoop,
       sdkDriver: sdkDriver
     });
     var roomStore = new loop.store.RoomStore(dispatcher, {
       mozLoop: navigator.mozLoop,
       activeRoomStore: activeRoomStore
     });
+    var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
+      feedbackClient: feedbackClient
+    });
 
     // XXX Old class creation for the incoming conversation view, whilst
     // we transition across (bug 1072323).
     var conversation = new sharedModels.ConversationModel(
       {},                // Model attributes
       {sdk: window.OT}   // Model dependencies
     );
 
@@ -692,16 +693,17 @@ loop.conversation = (function(mozL10n) {
       navigator.mozLoop.calls.clearCallInProgress(windowId);
 
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(AppControllerView({
       conversationAppStore: conversationAppStore, 
       roomStore: roomStore, 
+      feedbackStore: feedbackStore, 
       conversationStore: conversationStore, 
       client: client, 
       conversation: conversation, 
       dispatcher: dispatcher, 
       sdk: window.OT}
     ), document.querySelector('#main'));
 
     dispatcher.dispatch(new sharedActions.GetWindowData({
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -224,17 +224,18 @@ loop.conversation = (function(mozL10n) {
    */
   var IncomingConversationView = React.createClass({
     propTypes: {
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
       conversationAppStore: React.PropTypes.instanceOf(
-        loop.store.ConversationAppStore).isRequired
+        loop.store.ConversationAppStore).isRequired,
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
       return {
         callFailed: false, // XXX this should be removed when bug 1047410 lands.
         callStatus: "start"
       };
     },
@@ -296,31 +297,19 @@ loop.conversation = (function(mozL10n) {
           if (this.state.callFailed) {
             return <GenericFailureView
               cancelCall={this.closeWindow.bind(this)}
             />;
           }
 
           document.title = mozL10n.get("conversation_has_ended");
 
-          var feebackAPIBaseUrl = navigator.mozLoop.getLoopPref(
-            "feedback.baseUrl");
-
-          var appVersionInfo = navigator.mozLoop.appVersionInfo;
-
-          var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
-            product: navigator.mozLoop.getLoopPref("feedback.product"),
-            platform: appVersionInfo.OS,
-            channel: appVersionInfo.channel,
-            version: appVersionInfo.version
-          });
-
           return (
             <sharedViews.FeedbackView
-              feedbackApiClient={feedbackClient}
+              feedbackStore={this.props.feedbackStore}
               onAfterFeedbackReceived={this.closeWindow.bind(this)}
             />
           );
         }
         case "close": {
           window.close();
           return (<div/>);
         }
@@ -557,17 +546,18 @@ loop.conversation = (function(mozL10n) {
       sdk: React.PropTypes.object.isRequired,
 
       // XXX New types for flux style
       conversationAppStore: React.PropTypes.instanceOf(
         loop.store.ConversationAppStore).isRequired,
       conversationStore: React.PropTypes.instanceOf(loop.store.ConversationStore)
                               .isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
-      roomStore: React.PropTypes.instanceOf(loop.store.RoomStore)
+      roomStore: React.PropTypes.instanceOf(loop.store.RoomStore),
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
       return this.props.conversationAppStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.conversationAppStore, "change", function() {
@@ -586,35 +576,35 @@ loop.conversation = (function(mozL10n) {
     render: function() {
       switch(this.state.windowType) {
         case "incoming": {
           return (<IncomingConversationView
             client={this.props.client}
             conversation={this.props.conversation}
             sdk={this.props.sdk}
             conversationAppStore={this.props.conversationAppStore}
+            feedbackStore={this.props.feedbackStore}
           />);
         }
         case "outgoing": {
           return (<OutgoingConversationView
             store={this.props.conversationStore}
             dispatcher={this.props.dispatcher}
+            feedbackStore={this.props.feedbackStore}
           />);
         }
         case "room": {
           return (<DesktopRoomConversationView
             dispatcher={this.props.dispatcher}
             roomStore={this.props.roomStore}
-            dispatcher={this.props.dispatcher}
+            feedbackStore={this.props.feedbackStore}
           />);
         }
         case "failed": {
-          return (<GenericFailureView
-            cancelCall={this.closeWindow}
-          />);
+          return <GenericFailureView cancelCall={this.closeWindow} />;
         }
         default: {
           // If we don't have a windowType, we don't know what we are yet,
           // so don't display anything.
           return null;
         }
       }
     }
@@ -641,16 +631,24 @@ loop.conversation = (function(mozL10n) {
     });
 
     var dispatcher = new loop.Dispatcher();
     var client = new loop.Client();
     var sdkDriver = new loop.OTSdkDriver({
       dispatcher: dispatcher,
       sdk: OT
     });
+    var appVersionInfo = navigator.mozLoop.appVersionInfo;
+    var feedbackClient = new loop.FeedbackAPIClient(
+      navigator.mozLoop.getLoopPref("feedback.baseUrl"), {
+      product: navigator.mozLoop.getLoopPref("feedback.product"),
+      platform: appVersionInfo.OS,
+      channel: appVersionInfo.channel,
+      version: appVersionInfo.version
+    });
 
     // Create the stores.
     var conversationAppStore = new loop.store.ConversationAppStore({
       dispatcher: dispatcher,
       mozLoop: navigator.mozLoop
     });
     var conversationStore = new loop.store.ConversationStore({}, {
       client: client,
@@ -660,16 +658,19 @@ loop.conversation = (function(mozL10n) {
     var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
       mozLoop: navigator.mozLoop,
       sdkDriver: sdkDriver
     });
     var roomStore = new loop.store.RoomStore(dispatcher, {
       mozLoop: navigator.mozLoop,
       activeRoomStore: activeRoomStore
     });
+    var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
+      feedbackClient: feedbackClient
+    });
 
     // XXX Old class creation for the incoming conversation view, whilst
     // we transition across (bug 1072323).
     var conversation = new sharedModels.ConversationModel(
       {},                // Model attributes
       {sdk: window.OT}   // Model dependencies
     );
 
@@ -692,16 +693,17 @@ loop.conversation = (function(mozL10n) {
       navigator.mozLoop.calls.clearCallInProgress(windowId);
 
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(<AppControllerView
       conversationAppStore={conversationAppStore}
       roomStore={roomStore}
+      feedbackStore={feedbackStore}
       conversationStore={conversationStore}
       client={client}
       conversation={conversation}
       dispatcher={dispatcher}
       sdk={window.OT}
     />, document.querySelector('#main'));
 
     dispatcher.dispatch(new sharedActions.GetWindowData({
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -351,17 +351,17 @@ loop.conversationViews = (function(mozL1
         publishVideo: this.props.video.enabled,
         style: {
           audioLevelDisplayMode: "off",
           bugDisplayMode: "off",
           buttonDisplayMode: "off",
           nameDisplayMode: "off",
           videoDisabledDisplayMode: "off"
         }
-      }
+      };
     },
 
     /**
      * Used to update the video container whenever the orientation or size of the
      * display area changes.
      */
     updateVideoContainer: function() {
       var localStreamParent = this._getElement('.local .OT_publisher');
@@ -426,17 +426,18 @@ loop.conversationViews = (function(mozL1
   /**
    * 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
+        loop.store.ConversationStore).isRequired,
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
       return this.props.store.attributes;
     },
 
     componentWillMount: function() {
       this.props.store.on("change", function() {
@@ -457,32 +458,19 @@ loop.conversationViews = (function(mozL1
     },
 
     /**
      * 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.getLoopPref(
-        "feedback.baseUrl");
-
-      var appVersionInfo = navigator.mozLoop.appVersionInfo;
-
-      var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
-        product: navigator.mozLoop.getLoopPref("feedback.product"),
-        platform: appVersionInfo.OS,
-        channel: appVersionInfo.channel,
-        version: appVersionInfo.version
-      });
-
       return (
         sharedViews.FeedbackView({
-          feedbackApiClient: feedbackClient, 
+          feedbackStore: this.props.feedbackStore, 
           onAfterFeedbackReceived: this._closeWindow.bind(this)}
         )
       );
     },
 
     render: function() {
       switch (this.state.callState) {
         case CALL_STATES.CLOSE: {
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -351,17 +351,17 @@ loop.conversationViews = (function(mozL1
         publishVideo: this.props.video.enabled,
         style: {
           audioLevelDisplayMode: "off",
           bugDisplayMode: "off",
           buttonDisplayMode: "off",
           nameDisplayMode: "off",
           videoDisabledDisplayMode: "off"
         }
-      }
+      };
     },
 
     /**
      * Used to update the video container whenever the orientation or size of the
      * display area changes.
      */
     updateVideoContainer: function() {
       var localStreamParent = this._getElement('.local .OT_publisher');
@@ -426,17 +426,18 @@ loop.conversationViews = (function(mozL1
   /**
    * 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
+        loop.store.ConversationStore).isRequired,
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
       return this.props.store.attributes;
     },
 
     componentWillMount: function() {
       this.props.store.on("change", function() {
@@ -457,32 +458,19 @@ loop.conversationViews = (function(mozL1
     },
 
     /**
      * 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.getLoopPref(
-        "feedback.baseUrl");
-
-      var appVersionInfo = navigator.mozLoop.appVersionInfo;
-
-      var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
-        product: navigator.mozLoop.getLoopPref("feedback.product"),
-        platform: appVersionInfo.OS,
-        channel: appVersionInfo.channel,
-        version: appVersionInfo.version
-      });
-
       return (
         <sharedViews.FeedbackView
-          feedbackApiClient={feedbackClient}
+          feedbackStore={this.props.feedbackStore}
           onAfterFeedbackReceived={this._closeWindow.bind(this)}
         />
       );
     },
 
     render: function() {
       switch (this.state.callState) {
         case CALL_STATES.CLOSE: {
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -320,11 +320,33 @@ loop.shared.actions = (function() {
       sessionId: String,
       expires: Number
     }),
 
     /**
      * Used to indicate the user wishes to leave the room.
      */
     LeaveRoom: Action.define("leaveRoom", {
+    }),
+
+    /**
+     * Requires detailed information on sad feedback.
+     */
+    RequireFeedbackDetails: Action.define("requireFeedbackDetails", {
+    }),
+
+    /**
+     * Send feedback data.
+     */
+    SendFeedback: Action.define("sendFeedback", {
+      happy: Boolean,
+      category: String,
+      description: String
+    }),
+
+    /**
+     * Reacts on feedback submission error.
+     */
+    SendFeedbackError: Action.define("sendFeedbackError", {
+      error: Error
     })
   };
 })();
--- a/browser/components/loop/content/shared/js/feedbackApiClient.js
+++ b/browser/components/loop/content/shared/js/feedbackApiClient.js
@@ -102,16 +102,16 @@ loop.FeedbackAPIClient = (function($, _)
       req.done(function(result) {
         console.info("User feedback data have been submitted", result);
         cb(null, result);
       });
 
       req.fail(function(jqXHR, textStatus, errorThrown) {
         var message = "Error posting user feedback data";
         var httpError = jqXHR.status + " " + errorThrown;
-        console.error(message, httpError, JSON.stringify(jqXHR.responseJSON));
-        cb(new Error(message + ": " + httpError));
+        cb(new Error(message + ": " + httpError + "; " +
+                     (jqXHR.responseJSON && jqXHR.responseJSON.detail || "")));
       });
     }
   };
 
   return FeedbackAPIClient;
 })(jQuery, _);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/feedbackStore.js
@@ -0,0 +1,98 @@
+/* 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 */
+
+var loop = loop || {};
+loop.store = loop.store || {};
+
+loop.store.FeedbackStore = (function() {
+  "use strict";
+
+  var sharedActions = loop.shared.actions;
+  var FEEDBACK_STATES = loop.store.FEEDBACK_STATES = {
+    // Initial state (mood selection)
+    INIT: "feedback-init",
+    // User detailed feedback form step
+    DETAILS: "feedback-details",
+    // Pending feedback data submission
+    PENDING: "feedback-pending",
+    // Feedback has been sent
+    SENT: "feedback-sent",
+    // There was an issue with the feedback API
+    FAILED: "feedback-failed"
+  };
+
+  /**
+   * Feedback store.
+   *
+   * @param {loop.Dispatcher} dispatcher  The dispatcher for dispatching actions
+   *                                      and registering to consume actions.
+   * @param {Object} options Options object:
+   * - {mozLoop}        mozLoop                 The MozLoop API object.
+   * - {feedbackClient} loop.FeedbackAPIClient  The feedback API client.
+   */
+  var FeedbackStore = loop.store.createStore({
+    actions: [
+      "requireFeedbackDetails",
+      "sendFeedback",
+      "sendFeedbackError"
+    ],
+
+    initialize: function(options) {
+      if (!options.feedbackClient) {
+        throw new Error("Missing option feedbackClient");
+      }
+      this._feedbackClient = options.feedbackClient;
+    },
+
+    /**
+     * Returns initial state data for this active room.
+     */
+    getInitialStoreState: function() {
+      return {feedbackState: FEEDBACK_STATES.INIT};
+    },
+
+    /**
+     * Requires user detailed feedback.
+     */
+    requireFeedbackDetails: function() {
+      this.setStoreState({feedbackState: FEEDBACK_STATES.DETAILS});
+    },
+
+    /**
+     * Sends feedback data to the feedback server.
+     *
+     * @param {sharedActions.SendFeedback} actionData The action data.
+     */
+    sendFeedback: function(actionData) {
+      delete actionData.name;
+      this._feedbackClient.send(actionData, function(err) {
+        if (err) {
+          this.dispatchAction(new sharedActions.SendFeedbackError({
+            error: err
+          }));
+          return;
+        }
+        this.setStoreState({feedbackState: FEEDBACK_STATES.SENT});
+      }.bind(this));
+
+      this.setStoreState({feedbackState: FEEDBACK_STATES.PENDING});
+    },
+
+    /**
+     * Notifies a store from any error encountered while sending feedback data.
+     *
+     * @param {sharedActions.SendFeedback} actionData The action data.
+     */
+    sendFeedbackError: function(actionData) {
+      this.setStoreState({
+        feedbackState: FEEDBACK_STATES.FAILED,
+        error: actionData.error
+      });
+    }
+  });
+
+  return FeedbackStore;
+})();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/feedbackViews.js
@@ -0,0 +1,326 @@
+/** @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/. */
+
+/* jshint newcap:false */
+/* global loop:true, React */
+var loop = loop || {};
+loop.shared = loop.shared || {};
+loop.shared.views = loop.shared.views || {};
+loop.shared.views.FeedbackView = (function(l10n) {
+  "use strict";
+
+  var sharedActions = loop.shared.actions;
+  var sharedMixins = loop.shared.mixins;
+
+  var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
+  var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
+
+  /**
+   * Feedback outer layout.
+   *
+   * Props:
+   * -
+   */
+  var FeedbackLayout = React.createClass({displayName: 'FeedbackLayout',
+    propTypes: {
+      children: React.PropTypes.component.isRequired,
+      title: React.PropTypes.string.isRequired,
+      reset: React.PropTypes.func // if not specified, no Back btn is shown
+    },
+
+    render: function() {
+      var backButton = React.DOM.div(null);
+      if (this.props.reset) {
+        backButton = (
+          React.DOM.button({className: "fx-embedded-btn-back", type: "button", 
+                  onClick: this.props.reset}, 
+            "« ", l10n.get("feedback_back_button")
+          )
+        );
+      }
+      return (
+        React.DOM.div({className: "feedback"}, 
+          backButton, 
+          React.DOM.h3(null, this.props.title), 
+          this.props.children
+        )
+      );
+    }
+  });
+
+  /**
+   * Detailed feedback form.
+   */
+  var FeedbackForm = React.createClass({displayName: 'FeedbackForm',
+    propTypes: {
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
+      pending:       React.PropTypes.bool,
+      reset:         React.PropTypes.func
+    },
+
+    getInitialState: function() {
+      return {category: "", description: ""};
+    },
+
+    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"),
+        confusing:     l10n.get("feedback_category_confusing"),
+        other:         l10n.get("feedback_category_other")
+      };
+    },
+
+    _getCategoryFields: function() {
+      var categories = this._getCategories();
+      return Object.keys(categories).map(function(category, key) {
+        return (
+          React.DOM.label({key: key, className: "feedback-category-label"}, 
+            React.DOM.input({type: "radio", ref: "category", name: "category", 
+                   className: "feedback-category-radio", 
+                   value: category, 
+                   onChange: this.handleCategoryChange, 
+                   checked: this.state.category === category}), 
+            categories[category]
+          )
+        );
+      }, this);
+    },
+
+    /**
+     * Checks if the form is ready for submission:
+     *
+     * - no feedback submission should be pending.
+     * - a category (reason) must be chosen;
+     * - if the "other" category is chosen, a custom description must have been
+     *   entered by the end user;
+     *
+     * @return {Boolean}
+     */
+    _isFormReady: function() {
+      if (this.props.pending || !this.state.category) {
+        return false;
+      }
+      if (this.state.category === "other" && !this.state.description) {
+        return false;
+      }
+      return true;
+    },
+
+    handleCategoryChange: function(event) {
+      var category = event.target.value;
+      this.setState({
+        category: category,
+        description: category == "other" ? "" : this._getCategories()[category]
+      });
+      if (category == "other") {
+        this.refs.description.getDOMNode().focus();
+      }
+    },
+
+    handleDescriptionFieldChange: function(event) {
+      this.setState({description: event.target.value});
+    },
+
+    handleDescriptionFieldFocus: function(event) {
+      this.setState({category: "other", description: ""});
+    },
+
+    handleFormSubmit: function(event) {
+      event.preventDefault();
+      // XXX this feels ugly, we really want a feedbackActions object here.
+      this.props.feedbackStore.dispatchAction(new sharedActions.SendFeedback({
+        happy: false,
+        category: this.state.category,
+        description: this.state.description
+      }));
+    },
+
+    render: function() {
+      var descriptionDisplayValue = this.state.category === "other" ?
+                                    this.state.description : "";
+      return (
+        FeedbackLayout({title: l10n.get("feedback_what_makes_you_sad"), 
+                        reset: this.props.reset}, 
+          React.DOM.form({onSubmit: this.handleFormSubmit}, 
+            this._getCategoryFields(), 
+            React.DOM.p(null, 
+              React.DOM.input({type: "text", ref: "description", name: "description", 
+                className: "feedback-description", 
+                onChange: this.handleDescriptionFieldChange, 
+                onFocus: this.handleDescriptionFieldFocus, 
+                value: descriptionDisplayValue, 
+                placeholder: 
+                  l10n.get("feedback_custom_category_text_placeholder")})
+            ), 
+            React.DOM.button({type: "submit", className: "btn btn-success", 
+                    disabled: !this._isFormReady()}, 
+              l10n.get("feedback_submit_button")
+            )
+          )
+        )
+      );
+    }
+  });
+
+  /**
+   * 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);
+    },
+
+    componentWillUnmount: function() {
+      if (this._timer) {
+        clearInterval(this._timer);
+      }
+    },
+
+    render: function() {
+      if (this.state.countdown < 1) {
+        clearInterval(this._timer);
+        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
+            }))
+        )
+      );
+    }
+  });
+
+  /**
+   * Feedback view.
+   */
+  var FeedbackView = React.createClass({displayName: 'FeedbackView',
+    mixins: [Backbone.Events, sharedMixins.AudioMixin],
+
+    propTypes: {
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
+      onAfterFeedbackReceived: React.PropTypes.func,
+      // Used by the UI showcase.
+      feedbackState: React.PropTypes.string
+    },
+
+    getInitialState: function() {
+      var storeState = this.props.feedbackStore.getStoreState();
+      return _.extend({}, storeState, {
+        feedbackState: this.props.feedbackState || storeState.feedbackState
+      });
+    },
+
+    componentWillMount: function() {
+      this.listenTo(this.props.feedbackStore, "change", this._onStoreStateChanged);
+    },
+
+    componentDidMount: function() {
+      this.play("terminated");
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.feedbackStore);
+    },
+
+    _onStoreStateChanged: function() {
+      this.setState(this.props.feedbackStore.getStoreState());
+    },
+
+    reset: function() {
+      this.setState(this.props.feedbackStore.getInitialStoreState());
+    },
+
+    handleHappyClick: function() {
+      // XXX: If the user is happy, we directly send this information to the
+      //      feedback API; this is a behavior we might want to revisit later.
+      this.props.feedbackStore.dispatchAction(new sharedActions.SendFeedback({
+        happy: true,
+        category: "",
+        description: ""
+      }));
+    },
+
+    handleSadClick: function() {
+      this.props.feedbackStore.dispatchAction(
+        new sharedActions.RequireFeedbackDetails());
+    },
+
+    _onFeedbackSent: function(err) {
+      if (err) {
+        // XXX better end user error reporting, see bug 1046738
+        console.error("Unable to send user feedback", err);
+      }
+      this.setState({pending: false, step: "finished"});
+    },
+
+    render: function() {
+      switch(this.state.feedbackState) {
+        default:
+        case FEEDBACK_STATES.INIT: {
+          return (
+            FeedbackLayout({title: 
+              l10n.get("feedback_call_experience_heading2")}, 
+              React.DOM.div({className: "faces"}, 
+                React.DOM.button({className: "face face-happy", 
+                        onClick: this.handleHappyClick}), 
+                React.DOM.button({className: "face face-sad", 
+                        onClick: this.handleSadClick})
+              )
+            )
+          );
+        }
+        case FEEDBACK_STATES.DETAILS: {
+          return (
+            FeedbackForm({
+              feedbackStore: this.props.feedbackStore, 
+              reset: this.reset, 
+              pending: this.state.feedbackState === FEEDBACK_STATES.PENDING})
+            );
+        }
+        case FEEDBACK_STATES.PENDING:
+        case FEEDBACK_STATES.SENT:
+        case FEEDBACK_STATES.FAILED: {
+          if (this.state.error) {
+            // XXX better end user error reporting, see bug 1046738
+            console.error("Error encountered while submitting feedback",
+                          this.state.error);
+          }
+          return (
+            FeedbackReceived({
+              onAfterFeedbackReceived: this.props.onAfterFeedbackReceived})
+          );
+        }
+      }
+    }
+  });
+
+  return FeedbackView;
+})(navigator.mozL10n || document.mozL10n);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/feedbackViews.jsx
@@ -0,0 +1,326 @@
+/** @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/. */
+
+/* jshint newcap:false */
+/* global loop:true, React */
+var loop = loop || {};
+loop.shared = loop.shared || {};
+loop.shared.views = loop.shared.views || {};
+loop.shared.views.FeedbackView = (function(l10n) {
+  "use strict";
+
+  var sharedActions = loop.shared.actions;
+  var sharedMixins = loop.shared.mixins;
+
+  var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
+  var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
+
+  /**
+   * Feedback outer layout.
+   *
+   * Props:
+   * -
+   */
+  var FeedbackLayout = React.createClass({
+    propTypes: {
+      children: React.PropTypes.component.isRequired,
+      title: React.PropTypes.string.isRequired,
+      reset: React.PropTypes.func // if not specified, no Back btn is shown
+    },
+
+    render: function() {
+      var backButton = <div />;
+      if (this.props.reset) {
+        backButton = (
+          <button className="fx-embedded-btn-back" type="button"
+                  onClick={this.props.reset}>
+            &laquo;&nbsp;{l10n.get("feedback_back_button")}
+          </button>
+        );
+      }
+      return (
+        <div className="feedback">
+          {backButton}
+          <h3>{this.props.title}</h3>
+          {this.props.children}
+        </div>
+      );
+    }
+  });
+
+  /**
+   * Detailed feedback form.
+   */
+  var FeedbackForm = React.createClass({
+    propTypes: {
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
+      pending:       React.PropTypes.bool,
+      reset:         React.PropTypes.func
+    },
+
+    getInitialState: function() {
+      return {category: "", description: ""};
+    },
+
+    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"),
+        confusing:     l10n.get("feedback_category_confusing"),
+        other:         l10n.get("feedback_category_other")
+      };
+    },
+
+    _getCategoryFields: function() {
+      var categories = this._getCategories();
+      return Object.keys(categories).map(function(category, key) {
+        return (
+          <label key={key} className="feedback-category-label">
+            <input type="radio" ref="category" name="category"
+                   className="feedback-category-radio"
+                   value={category}
+                   onChange={this.handleCategoryChange}
+                   checked={this.state.category === category} />
+            {categories[category]}
+          </label>
+        );
+      }, this);
+    },
+
+    /**
+     * Checks if the form is ready for submission:
+     *
+     * - no feedback submission should be pending.
+     * - a category (reason) must be chosen;
+     * - if the "other" category is chosen, a custom description must have been
+     *   entered by the end user;
+     *
+     * @return {Boolean}
+     */
+    _isFormReady: function() {
+      if (this.props.pending || !this.state.category) {
+        return false;
+      }
+      if (this.state.category === "other" && !this.state.description) {
+        return false;
+      }
+      return true;
+    },
+
+    handleCategoryChange: function(event) {
+      var category = event.target.value;
+      this.setState({
+        category: category,
+        description: category == "other" ? "" : this._getCategories()[category]
+      });
+      if (category == "other") {
+        this.refs.description.getDOMNode().focus();
+      }
+    },
+
+    handleDescriptionFieldChange: function(event) {
+      this.setState({description: event.target.value});
+    },
+
+    handleDescriptionFieldFocus: function(event) {
+      this.setState({category: "other", description: ""});
+    },
+
+    handleFormSubmit: function(event) {
+      event.preventDefault();
+      // XXX this feels ugly, we really want a feedbackActions object here.
+      this.props.feedbackStore.dispatchAction(new sharedActions.SendFeedback({
+        happy: false,
+        category: this.state.category,
+        description: this.state.description
+      }));
+    },
+
+    render: function() {
+      var descriptionDisplayValue = this.state.category === "other" ?
+                                    this.state.description : "";
+      return (
+        <FeedbackLayout title={l10n.get("feedback_what_makes_you_sad")}
+                        reset={this.props.reset}>
+          <form onSubmit={this.handleFormSubmit}>
+            {this._getCategoryFields()}
+            <p>
+              <input type="text" ref="description" name="description"
+                className="feedback-description"
+                onChange={this.handleDescriptionFieldChange}
+                onFocus={this.handleDescriptionFieldFocus}
+                value={descriptionDisplayValue}
+                placeholder={
+                  l10n.get("feedback_custom_category_text_placeholder")} />
+            </p>
+            <button type="submit" className="btn btn-success"
+                    disabled={!this._isFormReady()}>
+              {l10n.get("feedback_submit_button")}
+            </button>
+          </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);
+    },
+
+    componentWillUnmount: function() {
+      if (this._timer) {
+        clearInterval(this._timer);
+      }
+    },
+
+    render: function() {
+      if (this.state.countdown < 1) {
+        clearInterval(this._timer);
+        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>
+        </FeedbackLayout>
+      );
+    }
+  });
+
+  /**
+   * Feedback view.
+   */
+  var FeedbackView = React.createClass({
+    mixins: [Backbone.Events, sharedMixins.AudioMixin],
+
+    propTypes: {
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
+      onAfterFeedbackReceived: React.PropTypes.func,
+      // Used by the UI showcase.
+      feedbackState: React.PropTypes.string
+    },
+
+    getInitialState: function() {
+      var storeState = this.props.feedbackStore.getStoreState();
+      return _.extend({}, storeState, {
+        feedbackState: this.props.feedbackState || storeState.feedbackState
+      });
+    },
+
+    componentWillMount: function() {
+      this.listenTo(this.props.feedbackStore, "change", this._onStoreStateChanged);
+    },
+
+    componentDidMount: function() {
+      this.play("terminated");
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.feedbackStore);
+    },
+
+    _onStoreStateChanged: function() {
+      this.setState(this.props.feedbackStore.getStoreState());
+    },
+
+    reset: function() {
+      this.setState(this.props.feedbackStore.getInitialStoreState());
+    },
+
+    handleHappyClick: function() {
+      // XXX: If the user is happy, we directly send this information to the
+      //      feedback API; this is a behavior we might want to revisit later.
+      this.props.feedbackStore.dispatchAction(new sharedActions.SendFeedback({
+        happy: true,
+        category: "",
+        description: ""
+      }));
+    },
+
+    handleSadClick: function() {
+      this.props.feedbackStore.dispatchAction(
+        new sharedActions.RequireFeedbackDetails());
+    },
+
+    _onFeedbackSent: function(err) {
+      if (err) {
+        // XXX better end user error reporting, see bug 1046738
+        console.error("Unable to send user feedback", err);
+      }
+      this.setState({pending: false, step: "finished"});
+    },
+
+    render: function() {
+      switch(this.state.feedbackState) {
+        default:
+        case FEEDBACK_STATES.INIT: {
+          return (
+            <FeedbackLayout title={
+              l10n.get("feedback_call_experience_heading2")}>
+              <div className="faces">
+                <button className="face face-happy"
+                        onClick={this.handleHappyClick}></button>
+                <button className="face face-sad"
+                        onClick={this.handleSadClick}></button>
+              </div>
+            </FeedbackLayout>
+          );
+        }
+        case FEEDBACK_STATES.DETAILS: {
+          return (
+            <FeedbackForm
+              feedbackStore={this.props.feedbackStore}
+              reset={this.reset}
+              pending={this.state.feedbackState === FEEDBACK_STATES.PENDING} />
+            );
+        }
+        case FEEDBACK_STATES.PENDING:
+        case FEEDBACK_STATES.SENT:
+        case FEEDBACK_STATES.FAILED: {
+          if (this.state.error) {
+            // XXX better end user error reporting, see bug 1046738
+            console.error("Error encountered while submitting feedback",
+                          this.state.error);
+          }
+          return (
+            <FeedbackReceived
+              onAfterFeedbackReceived={this.props.onAfterFeedbackReceived} />
+          );
+        }
+      }
+    }
+  });
+
+  return FeedbackView;
+})(navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -9,18 +9,16 @@
 var loop = loop || {};
 loop.shared = loop.shared || {};
 loop.shared.views = (function(_, OT, l10n) {
   "use strict";
 
   var sharedModels = loop.shared.models;
   var sharedMixins = loop.shared.mixins;
 
-  var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
-
   /**
    * Media control button.
    *
    * Required props:
    * - {String}   scope   Media scope, can be "local" or "remote".
    * - {String}   type    Media type, can be "audio" or "video".
    * - {Function} action  Function to be executed on click.
    * - {Enabled}  enabled Stream activation status (default: true).
@@ -341,297 +339,16 @@ loop.shared.views = (function(_, OT, l10
           )
         )
       );
       /* jshint ignore:end */
     }
   });
 
   /**
-   * Feedback outer layout.
-   *
-   * Props:
-   * -
-   */
-  var FeedbackLayout = React.createClass({displayName: 'FeedbackLayout',
-    propTypes: {
-      children: React.PropTypes.component.isRequired,
-      title: React.PropTypes.string.isRequired,
-      reset: React.PropTypes.func // if not specified, no Back btn is shown
-    },
-
-    render: function() {
-      var backButton = React.DOM.div(null);
-      if (this.props.reset) {
-        backButton = (
-          React.DOM.button({className: "fx-embedded-btn-back", type: "button", 
-                  onClick: this.props.reset}, 
-            "« ", l10n.get("feedback_back_button")
-          )
-        );
-      }
-      return (
-        React.DOM.div({className: "feedback"}, 
-          backButton, 
-          React.DOM.h3(null, this.props.title), 
-          this.props.children
-        )
-      );
-    }
-  });
-
-  /**
-   * Detailed feedback form.
-   */
-  var FeedbackForm = React.createClass({displayName: 'FeedbackForm',
-    propTypes: {
-      pending:      React.PropTypes.bool,
-      sendFeedback: React.PropTypes.func,
-      reset:        React.PropTypes.func
-    },
-
-    getInitialState: function() {
-      return {category: "", description: ""};
-    },
-
-    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"),
-        confusing:     l10n.get("feedback_category_confusing"),
-        other:         l10n.get("feedback_category_other")
-      };
-    },
-
-    _getCategoryFields: function() {
-      var categories = this._getCategories();
-      return Object.keys(categories).map(function(category, key) {
-        return (
-          React.DOM.label({key: key, className: "feedback-category-label"}, 
-            React.DOM.input({type: "radio", ref: "category", name: "category", 
-                   className: "feedback-category-radio", 
-                   value: category, 
-                   onChange: this.handleCategoryChange, 
-                   checked: this.state.category === category}), 
-            categories[category]
-          )
-        );
-      }, this);
-    },
-
-    /**
-     * Checks if the form is ready for submission:
-     *
-     * - no feedback submission should be pending.
-     * - a category (reason) must be chosen;
-     * - if the "other" category is chosen, a custom description must have been
-     *   entered by the end user;
-     *
-     * @return {Boolean}
-     */
-    _isFormReady: function() {
-      if (this.props.pending || !this.state.category) {
-        return false;
-      }
-      if (this.state.category === "other" && !this.state.description) {
-        return false;
-      }
-      return true;
-    },
-
-    handleCategoryChange: function(event) {
-      var category = event.target.value;
-      this.setState({
-        category: category,
-        description: category == "other" ? "" : this._getCategories()[category]
-      });
-      if (category == "other") {
-        this.refs.description.getDOMNode().focus();
-      }
-    },
-
-    handleDescriptionFieldChange: function(event) {
-      this.setState({description: event.target.value});
-    },
-
-    handleDescriptionFieldFocus: function(event) {
-      this.setState({category: "other", description: ""});
-    },
-
-    handleFormSubmit: function(event) {
-      event.preventDefault();
-      this.props.sendFeedback({
-        happy: false,
-        category: this.state.category,
-        description: this.state.description
-      });
-    },
-
-    render: function() {
-      var descriptionDisplayValue = this.state.category === "other" ?
-                                    this.state.description : "";
-      return (
-        FeedbackLayout({title: l10n.get("feedback_what_makes_you_sad"), 
-                        reset: this.props.reset}, 
-          React.DOM.form({onSubmit: this.handleFormSubmit}, 
-            this._getCategoryFields(), 
-            React.DOM.p(null, 
-              React.DOM.input({type: "text", ref: "description", name: "description", 
-                className: "feedback-description", 
-                onChange: this.handleDescriptionFieldChange, 
-                onFocus: this.handleDescriptionFieldFocus, 
-                value: descriptionDisplayValue, 
-                placeholder: 
-                  l10n.get("feedback_custom_category_text_placeholder")})
-            ), 
-            React.DOM.button({type: "submit", className: "btn btn-success", 
-                    disabled: !this._isFormReady()}, 
-              l10n.get("feedback_submit_button")
-            )
-          )
-        )
-      );
-    }
-  });
-
-  /**
-   * 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);
-    },
-
-    componentWillUnmount: function() {
-      if (this._timer) {
-        clearInterval(this._timer);
-      }
-    },
-
-    render: function() {
-      if (this.state.countdown < 1) {
-        clearInterval(this._timer);
-        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
-            }))
-        )
-      );
-    }
-  });
-
-  /**
-   * Feedback view.
-   */
-  var FeedbackView = React.createClass({displayName: 'FeedbackView',
-    mixins: [sharedMixins.AudioMixin],
-
-    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"};
-    },
-
-    getDefaultProps: function() {
-      return {step: "start"};
-    },
-
-    componentDidMount: function() {
-      this.play("terminated");
-    },
-
-    reset: function() {
-      this.setState(this.getInitialState());
-    },
-
-    handleHappyClick: function() {
-      this.sendFeedback({happy: true}, this._onFeedbackSent);
-    },
-
-    handleSadClick: function() {
-      this.setState({step: "form"});
-    },
-
-    sendFeedback: function(fields) {
-      // Setting state.pending to true will disable the submit button to avoid
-      // multiple submissions
-      this.setState({pending: true});
-      // Sends feedback data
-      this.props.feedbackApiClient.send(fields, this._onFeedbackSent);
-    },
-
-    _onFeedbackSent: function(err) {
-      if (err) {
-        // XXX better end user error reporting, see bug 1046738
-        console.error("Unable to send user feedback", err);
-      }
-      this.setState({pending: false, step: "finished"});
-    },
-
-    render: function() {
-      switch(this.state.step) {
-        case "finished":
-          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: 
-              l10n.get("feedback_call_experience_heading2")}, 
-              React.DOM.div({className: "faces"}, 
-                React.DOM.button({className: "face face-happy", 
-                        onClick: this.handleHappyClick}), 
-                React.DOM.button({className: "face face-sad", 
-                        onClick: this.handleSadClick})
-              )
-            )
-          );
-      }
-    }
-  });
-
-  /**
    * Notification view.
    */
   var NotificationView = React.createClass({displayName: 'NotificationView',
     mixins: [Backbone.Events],
 
     propTypes: {
       notification: React.PropTypes.object.isRequired,
       key: React.PropTypes.number.isRequired
@@ -738,17 +455,17 @@ loop.shared.views = (function(_, OT, l10
       return (
         React.DOM.button({onClick: this.props.onClick, 
                 disabled: this.props.disabled, 
                 id: this.props.htmlId, 
                 className: cx(classObject)}, 
           React.DOM.span({className: "button-caption"}, this.props.caption), 
           this.props.children
         )
-      )
+      );
     }
   });
 
   var ButtonGroup = React.createClass({displayName: 'ButtonGroup',
     PropTypes: {
       additionalClass: React.PropTypes.string
     },
 
@@ -763,22 +480,21 @@ loop.shared.views = (function(_, OT, l10
       var classObject = { "button-group": true };
       if (this.props.additionalClass) {
         classObject[this.props.additionalClass] = true;
       }
       return (
         React.DOM.div({className: cx(classObject)}, 
           this.props.children
         )
-      )
+      );
     }
   });
 
   return {
     Button: Button,
     ButtonGroup: ButtonGroup,
     ConversationView: ConversationView,
     ConversationToolbar: ConversationToolbar,
-    FeedbackView: FeedbackView,
     MediaControlButton: MediaControlButton,
     NotificationListView: NotificationListView
   };
 })(_, window.OT, navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -9,18 +9,16 @@
 var loop = loop || {};
 loop.shared = loop.shared || {};
 loop.shared.views = (function(_, OT, l10n) {
   "use strict";
 
   var sharedModels = loop.shared.models;
   var sharedMixins = loop.shared.mixins;
 
-  var WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS = 5;
-
   /**
    * Media control button.
    *
    * Required props:
    * - {String}   scope   Media scope, can be "local" or "remote".
    * - {String}   type    Media type, can be "audio" or "video".
    * - {Function} action  Function to be executed on click.
    * - {Enabled}  enabled Stream activation status (default: true).
@@ -341,297 +339,16 @@ loop.shared.views = (function(_, OT, l10
           </div>
         </div>
       );
       /* jshint ignore:end */
     }
   });
 
   /**
-   * Feedback outer layout.
-   *
-   * Props:
-   * -
-   */
-  var FeedbackLayout = React.createClass({
-    propTypes: {
-      children: React.PropTypes.component.isRequired,
-      title: React.PropTypes.string.isRequired,
-      reset: React.PropTypes.func // if not specified, no Back btn is shown
-    },
-
-    render: function() {
-      var backButton = <div />;
-      if (this.props.reset) {
-        backButton = (
-          <button className="fx-embedded-btn-back" type="button"
-                  onClick={this.props.reset}>
-            &laquo;&nbsp;{l10n.get("feedback_back_button")}
-          </button>
-        );
-      }
-      return (
-        <div className="feedback">
-          {backButton}
-          <h3>{this.props.title}</h3>
-          {this.props.children}
-        </div>
-      );
-    }
-  });
-
-  /**
-   * Detailed feedback form.
-   */
-  var FeedbackForm = React.createClass({
-    propTypes: {
-      pending:      React.PropTypes.bool,
-      sendFeedback: React.PropTypes.func,
-      reset:        React.PropTypes.func
-    },
-
-    getInitialState: function() {
-      return {category: "", description: ""};
-    },
-
-    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"),
-        confusing:     l10n.get("feedback_category_confusing"),
-        other:         l10n.get("feedback_category_other")
-      };
-    },
-
-    _getCategoryFields: function() {
-      var categories = this._getCategories();
-      return Object.keys(categories).map(function(category, key) {
-        return (
-          <label key={key} className="feedback-category-label">
-            <input type="radio" ref="category" name="category"
-                   className="feedback-category-radio"
-                   value={category}
-                   onChange={this.handleCategoryChange}
-                   checked={this.state.category === category} />
-            {categories[category]}
-          </label>
-        );
-      }, this);
-    },
-
-    /**
-     * Checks if the form is ready for submission:
-     *
-     * - no feedback submission should be pending.
-     * - a category (reason) must be chosen;
-     * - if the "other" category is chosen, a custom description must have been
-     *   entered by the end user;
-     *
-     * @return {Boolean}
-     */
-    _isFormReady: function() {
-      if (this.props.pending || !this.state.category) {
-        return false;
-      }
-      if (this.state.category === "other" && !this.state.description) {
-        return false;
-      }
-      return true;
-    },
-
-    handleCategoryChange: function(event) {
-      var category = event.target.value;
-      this.setState({
-        category: category,
-        description: category == "other" ? "" : this._getCategories()[category]
-      });
-      if (category == "other") {
-        this.refs.description.getDOMNode().focus();
-      }
-    },
-
-    handleDescriptionFieldChange: function(event) {
-      this.setState({description: event.target.value});
-    },
-
-    handleDescriptionFieldFocus: function(event) {
-      this.setState({category: "other", description: ""});
-    },
-
-    handleFormSubmit: function(event) {
-      event.preventDefault();
-      this.props.sendFeedback({
-        happy: false,
-        category: this.state.category,
-        description: this.state.description
-      });
-    },
-
-    render: function() {
-      var descriptionDisplayValue = this.state.category === "other" ?
-                                    this.state.description : "";
-      return (
-        <FeedbackLayout title={l10n.get("feedback_what_makes_you_sad")}
-                        reset={this.props.reset}>
-          <form onSubmit={this.handleFormSubmit}>
-            {this._getCategoryFields()}
-            <p>
-              <input type="text" ref="description" name="description"
-                className="feedback-description"
-                onChange={this.handleDescriptionFieldChange}
-                onFocus={this.handleDescriptionFieldFocus}
-                value={descriptionDisplayValue}
-                placeholder={
-                  l10n.get("feedback_custom_category_text_placeholder")} />
-            </p>
-            <button type="submit" className="btn btn-success"
-                    disabled={!this._isFormReady()}>
-              {l10n.get("feedback_submit_button")}
-            </button>
-          </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);
-    },
-
-    componentWillUnmount: function() {
-      if (this._timer) {
-        clearInterval(this._timer);
-      }
-    },
-
-    render: function() {
-      if (this.state.countdown < 1) {
-        clearInterval(this._timer);
-        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>
-        </FeedbackLayout>
-      );
-    }
-  });
-
-  /**
-   * Feedback view.
-   */
-  var FeedbackView = React.createClass({
-    mixins: [sharedMixins.AudioMixin],
-
-    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"};
-    },
-
-    getDefaultProps: function() {
-      return {step: "start"};
-    },
-
-    componentDidMount: function() {
-      this.play("terminated");
-    },
-
-    reset: function() {
-      this.setState(this.getInitialState());
-    },
-
-    handleHappyClick: function() {
-      this.sendFeedback({happy: true}, this._onFeedbackSent);
-    },
-
-    handleSadClick: function() {
-      this.setState({step: "form"});
-    },
-
-    sendFeedback: function(fields) {
-      // Setting state.pending to true will disable the submit button to avoid
-      // multiple submissions
-      this.setState({pending: true});
-      // Sends feedback data
-      this.props.feedbackApiClient.send(fields, this._onFeedbackSent);
-    },
-
-    _onFeedbackSent: function(err) {
-      if (err) {
-        // XXX better end user error reporting, see bug 1046738
-        console.error("Unable to send user feedback", err);
-      }
-      this.setState({pending: false, step: "finished"});
-    },
-
-    render: function() {
-      switch(this.state.step) {
-        case "finished":
-          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={
-              l10n.get("feedback_call_experience_heading2")}>
-              <div className="faces">
-                <button className="face face-happy"
-                        onClick={this.handleHappyClick}></button>
-                <button className="face face-sad"
-                        onClick={this.handleSadClick}></button>
-              </div>
-            </FeedbackLayout>
-          );
-      }
-    }
-  });
-
-  /**
    * Notification view.
    */
   var NotificationView = React.createClass({
     mixins: [Backbone.Events],
 
     propTypes: {
       notification: React.PropTypes.object.isRequired,
       key: React.PropTypes.number.isRequired
@@ -738,17 +455,17 @@ loop.shared.views = (function(_, OT, l10
       return (
         <button onClick={this.props.onClick}
                 disabled={this.props.disabled}
                 id={this.props.htmlId}
                 className={cx(classObject)}>
           <span className="button-caption">{this.props.caption}</span>
           {this.props.children}
         </button>
-      )
+      );
     }
   });
 
   var ButtonGroup = React.createClass({
     PropTypes: {
       additionalClass: React.PropTypes.string
     },
 
@@ -763,22 +480,21 @@ loop.shared.views = (function(_, OT, l10
       var classObject = { "button-group": true };
       if (this.props.additionalClass) {
         classObject[this.props.additionalClass] = true;
       }
       return (
         <div className={cx(classObject)}>
           {this.props.children}
         </div>
-      )
+      );
     }
   });
 
   return {
     Button: Button,
     ButtonGroup: ButtonGroup,
     ConversationView: ConversationView,
     ConversationToolbar: ConversationToolbar,
-    FeedbackView: FeedbackView,
     MediaControlButton: MediaControlButton,
     NotificationListView: NotificationListView
   };
 })(_, window.OT, navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -66,22 +66,24 @@ browser.jar:
   content/browser/loop/shared/img/telefonica@2x.png             (content/shared/img/telefonica@2x.png)
 
   # Shared scripts
   content/browser/loop/shared/js/actions.js           (content/shared/js/actions.js)
   content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js)
   content/browser/loop/shared/js/store.js             (content/shared/js/store.js)
   content/browser/loop/shared/js/roomStore.js         (content/shared/js/roomStore.js)
   content/browser/loop/shared/js/activeRoomStore.js   (content/shared/js/activeRoomStore.js)
+  content/browser/loop/shared/js/feedbackStore.js     (content/shared/js/feedbackStore.js)
   content/browser/loop/shared/js/dispatcher.js        (content/shared/js/dispatcher.js)
   content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
   content/browser/loop/shared/js/models.js            (content/shared/js/models.js)
   content/browser/loop/shared/js/mixins.js            (content/shared/js/mixins.js)
   content/browser/loop/shared/js/otSdkDriver.js       (content/shared/js/otSdkDriver.js)
   content/browser/loop/shared/js/views.js             (content/shared/js/views.js)
+  content/browser/loop/shared/js/feedbackViews.js     (content/shared/js/feedbackViews.js)
   content/browser/loop/shared/js/utils.js             (content/shared/js/utils.js)
   content/browser/loop/shared/js/validate.js          (content/shared/js/validate.js)
   content/browser/loop/shared/js/websocket.js         (content/shared/js/websocket.js)
 
   # Shared libs
 #ifdef DEBUG
   content/browser/loop/shared/libs/react-0.11.2.js    (content/shared/libs/react-0.11.2.js)
 #else
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -94,16 +94,18 @@
     <script type="text/javascript" src="shared/js/feedbackApiClient.js"></script>
     <script type="text/javascript" src="shared/js/actions.js"></script>
     <script type="text/javascript" src="shared/js/validate.js"></script>
     <script type="text/javascript" src="shared/js/dispatcher.js"></script>
     <script type="text/javascript" src="shared/js/websocket.js"></script>
     <script type="text/javascript" src="shared/js/otSdkDriver.js"></script>
     <script type="text/javascript" src="shared/js/store.js"></script>
     <script type="text/javascript" src="shared/js/activeRoomStore.js"></script>
+    <script type="text/javascript" src="shared/js/feedbackStore.js"></script>
+    <script type="text/javascript" src="shared/js/feedbackViews.js"></script>
     <script type="text/javascript" src="js/standaloneAppStore.js"></script>
     <script type="text/javascript" src="js/standaloneClient.js"></script>
     <script type="text/javascript" src="js/standaloneMozLoop.js"></script>
     <script type="text/javascript" src="js/standaloneRoomViews.js"></script>
     <script type="text/javascript" src="js/webapp.js"></script>
 
     <script>
       // Wait for all the localization notes to load
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -534,25 +534,25 @@ loop.webapp = (function($, _, OT, mozL10
   /**
    * 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,
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
       onAfterFeedbackReceived: React.PropTypes.func.isRequired
     },
 
     render: function() {
       return (
         React.DOM.div({className: "ended-conversation"}, 
           sharedViews.FeedbackView({
-            feedbackApiClient: this.props.feedbackApiClient, 
+            feedbackStore: this.props.feedbackStore, 
             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}}
@@ -600,17 +600,17 @@ loop.webapp = (function($, _, OT, mozL10
       conversation: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(sharedModels.ConversationModel),
         React.PropTypes.instanceOf(FxOSConversationModel)
       ]).isRequired,
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
-      feedbackApiClient: React.PropTypes.object.isRequired
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
       return {
         callStatus: "start"
       };
     },
 
@@ -676,17 +676,17 @@ loop.webapp = (function($, _, OT, mozL10
             )
           );
         }
         case "end": {
           return (
             EndedConversationView({
               sdk: this.props.sdk, 
               conversation: this.props.conversation, 
-              feedbackApiClient: this.props.feedbackApiClient, 
+              feedbackStore: this.props.feedbackStore, 
               onAfterFeedbackReceived: this.callStatusSwitcher("start")}
             )
           );
         }
         case "expired": {
           return (
             CallUrlExpiredView({helper: this.props.helper})
           );
@@ -873,24 +873,24 @@ loop.webapp = (function($, _, OT, mozL10
       conversation: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(sharedModels.ConversationModel),
         React.PropTypes.instanceOf(FxOSConversationModel)
       ]).isRequired,
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
-      feedbackApiClient: React.PropTypes.object.isRequired,
 
       // XXX New types for flux style
       standaloneAppStore: React.PropTypes.instanceOf(
         loop.store.StandaloneAppStore).isRequired,
       activeRoomStore: React.PropTypes.instanceOf(
         loop.store.ActiveRoomStore).isRequired,
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
       return this.props.standaloneAppStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.standaloneAppStore, "change", function() {
@@ -917,17 +917,17 @@ loop.webapp = (function($, _, OT, mozL10
         case "outgoing": {
           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}
+               feedbackStore: this.props.feedbackStore}
             )
           );
         }
         case "room": {
           return (
             loop.standaloneRoomViews.StandaloneRoomView({
               activeRoomStore: this.props.activeRoomStore, 
               dispatcher: this.props.dispatcher, 
@@ -978,39 +978,49 @@ loop.webapp = (function($, _, OT, mozL10
     var dispatcher = new loop.Dispatcher();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
     var sdkDriver = new loop.OTSdkDriver({
       dispatcher: dispatcher,
       sdk: OT
     });
+    var feedbackClient = new loop.FeedbackAPIClient(
+      loop.config.feedbackApiUrl, {
+      product: loop.config.feedbackProductName,
+      user_agent: navigator.userAgent,
+      url: document.location.origin
+    });
 
+    // Stores
     var standaloneAppStore = new loop.store.StandaloneAppStore({
       conversation: conversation,
       dispatcher: dispatcher,
       helper: helper,
       sdk: OT
     });
     var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
       mozLoop: standaloneMozLoop,
       sdkDriver: sdkDriver
     });
+    var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
+      feedbackClient: feedbackClient
+    });
 
     window.addEventListener("unload", function() {
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(WebappRootView({
       client: client, 
       conversation: conversation, 
       helper: helper, 
       notifications: notifications, 
       sdk: OT, 
-      feedbackApiClient: feedbackApiClient, 
+      feedbackStore: feedbackStore, 
       standaloneAppStore: standaloneAppStore, 
       activeRoomStore: activeRoomStore, 
       dispatcher: dispatcher}
     ), 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;
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -534,25 +534,25 @@ loop.webapp = (function($, _, OT, mozL10
   /**
    * 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,
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
       onAfterFeedbackReceived: React.PropTypes.func.isRequired
     },
 
     render: function() {
       return (
         <div className="ended-conversation">
           <sharedViews.FeedbackView
-            feedbackApiClient={this.props.feedbackApiClient}
+            feedbackStore={this.props.feedbackStore}
             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}}
@@ -600,17 +600,17 @@ loop.webapp = (function($, _, OT, mozL10
       conversation: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(sharedModels.ConversationModel),
         React.PropTypes.instanceOf(FxOSConversationModel)
       ]).isRequired,
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
-      feedbackApiClient: React.PropTypes.object.isRequired
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
       return {
         callStatus: "start"
       };
     },
 
@@ -676,17 +676,17 @@ loop.webapp = (function($, _, OT, mozL10
             />
           );
         }
         case "end": {
           return (
             <EndedConversationView
               sdk={this.props.sdk}
               conversation={this.props.conversation}
-              feedbackApiClient={this.props.feedbackApiClient}
+              feedbackStore={this.props.feedbackStore}
               onAfterFeedbackReceived={this.callStatusSwitcher("start")}
             />
           );
         }
         case "expired": {
           return (
             <CallUrlExpiredView helper={this.props.helper} />
           );
@@ -873,24 +873,24 @@ loop.webapp = (function($, _, OT, mozL10
       conversation: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(sharedModels.ConversationModel),
         React.PropTypes.instanceOf(FxOSConversationModel)
       ]).isRequired,
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
-      feedbackApiClient: React.PropTypes.object.isRequired,
 
       // XXX New types for flux style
       standaloneAppStore: React.PropTypes.instanceOf(
         loop.store.StandaloneAppStore).isRequired,
       activeRoomStore: React.PropTypes.instanceOf(
         loop.store.ActiveRoomStore).isRequired,
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
       return this.props.standaloneAppStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.standaloneAppStore, "change", function() {
@@ -917,17 +917,17 @@ loop.webapp = (function($, _, OT, mozL10
         case "outgoing": {
           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}
+               feedbackStore={this.props.feedbackStore}
             />
           );
         }
         case "room": {
           return (
             <loop.standaloneRoomViews.StandaloneRoomView
               activeRoomStore={this.props.activeRoomStore}
               dispatcher={this.props.dispatcher}
@@ -978,39 +978,49 @@ loop.webapp = (function($, _, OT, mozL10
     var dispatcher = new loop.Dispatcher();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
     var sdkDriver = new loop.OTSdkDriver({
       dispatcher: dispatcher,
       sdk: OT
     });
+    var feedbackClient = new loop.FeedbackAPIClient(
+      loop.config.feedbackApiUrl, {
+      product: loop.config.feedbackProductName,
+      user_agent: navigator.userAgent,
+      url: document.location.origin
+    });
 
+    // Stores
     var standaloneAppStore = new loop.store.StandaloneAppStore({
       conversation: conversation,
       dispatcher: dispatcher,
       helper: helper,
       sdk: OT
     });
     var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
       mozLoop: standaloneMozLoop,
       sdkDriver: sdkDriver
     });
+    var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
+      feedbackClient: feedbackClient
+    });
 
     window.addEventListener("unload", function() {
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(<WebappRootView
       client={client}
       conversation={conversation}
       helper={helper}
       notifications={notifications}
       sdk={OT}
-      feedbackApiClient={feedbackApiClient}
+      feedbackStore={feedbackStore}
       standaloneAppStore={standaloneAppStore}
       activeRoomStore={activeRoomStore}
       dispatcher={dispatcher}
     />, 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;
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -440,32 +440,36 @@ describe("loop.conversationViews", funct
 
       var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
 
       expect(muteBtn.classList.contains("muted")).eql(true);
     });
   });
 
   describe("OutgoingConversationView", function() {
-    var store;
+    var store, feedbackStore;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversationViews.OutgoingConversationView({
           dispatcher: dispatcher,
-          store: store
+          store: store,
+          feedbackStore: feedbackStore
         }));
     }
 
     beforeEach(function() {
       store = new loop.store.ConversationStore({}, {
         dispatcher: dispatcher,
         client: {},
         sdkDriver: {}
       });
+      feedbackStore = new loop.store.FeedbackStore(dispatcher, {
+        feedbackClient: {}
+      });
     });
 
     it("should render the CallFailedView when the call state is 'terminated'",
       function() {
         store.set({callState: CALL_STATES.TERMINATED});
 
         view = mountTestComponent();
 
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -228,40 +228,45 @@ describe("loop.conversation", function()
       ccView = mountTestComponent();
 
       TestUtils.findRenderedComponentWithType(ccView,
         loop.conversation.GenericFailureView);
     });
   });
 
   describe("IncomingConversationView", function() {
-    var conversationAppStore, conversation, client, icView, oldTitle;
+    var conversationAppStore, conversation, client, icView, oldTitle,
+        feedbackStore;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversation.IncomingConversationView({
           client: client,
           conversation: conversation,
           sdk: {},
-          conversationAppStore: conversationAppStore
+          conversationAppStore: conversationAppStore,
+          feedbackStore: feedbackStore
         }));
     }
 
     beforeEach(function() {
       oldTitle = document.title;
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
         sdk: {}
       });
       conversation.set({windowId: 42});
       var dispatcher = new loop.Dispatcher();
       conversationAppStore = new loop.store.ConversationAppStore({
         dispatcher: dispatcher,
         mozLoop: navigator.mozLoop
       });
+      feedbackStore = new loop.store.FeedbackStore(dispatcher, {
+        feedbackClient: {}
+      });
       sandbox.stub(conversation, "setOutgoingSessionData");
     });
 
     afterEach(function() {
       icView = undefined;
       document.title = oldTitle;
     });
 
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -41,16 +41,18 @@
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
   <script src="../../content/shared/js/otSdkDriver.js"></script>
   <script src="../../content/shared/js/store.js"></script>
   <script src="../../content/shared/js/roomStore.js"></script>
   <script src="../../content/shared/js/activeRoomStore.js"></script>
+  <script src="../../content/shared/js/feedbackStore.js"></script>
+  <script src="../../content/shared/js/feedbackViews.js"></script>
   <script src="../../content/js/client.js"></script>
   <script src="../../content/js/conversationAppStore.js"></script>
   <script src="../../content/js/roomViews.js"></script>
   <script src="../../content/js/conversationViews.js"></script>
   <script src="../../content/js/conversation.js"></script>
   <script type="text/javascript;version=1.8" src="../../content/js/contacts.js"></script>
   <script src="../../content/js/panel.js"></script>
 
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/feedbackStore_test.js
@@ -0,0 +1,108 @@
+/* global chai, loop */
+
+var expect = chai.expect;
+var sharedActions = loop.shared.actions;
+
+describe("loop.store.FeedbackStore", function () {
+  "use strict";
+
+  var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
+  var sandbox, dispatcher, store, feedbackClient;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+
+    dispatcher = new loop.Dispatcher();
+
+    feedbackClient = new loop.FeedbackAPIClient("http://invalid", {
+      product: "Loop"
+    });
+
+    store = new loop.store.FeedbackStore(dispatcher, {
+      feedbackClient: feedbackClient
+    });
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("#constructor", function() {
+    it("should throw an error if feedbackClient is missing", function() {
+      expect(function() {
+        new loop.store.FeedbackStore(dispatcher);
+      }).to.Throw(/feedbackClient/);
+    });
+
+    it("should set the store to the INIT feedback state", function() {
+      var store = new loop.store.FeedbackStore(dispatcher, {
+        feedbackClient: feedbackClient
+      });
+
+      expect(store.getStoreState("feedbackState")).eql(FEEDBACK_STATES.INIT);
+    });
+  });
+
+  describe("#requireFeedbackDetails", function() {
+    it("should transition to DETAILS state", function() {
+      store.requireFeedbackDetails(new sharedActions.RequireFeedbackDetails());
+
+      expect(store.getStoreState("feedbackState")).eql(FEEDBACK_STATES.DETAILS);
+    });
+  });
+
+  describe("#sendFeedback", function() {
+    var sadFeedbackData = {
+      happy: false,
+      category: "fakeCategory",
+      description: "fakeDescription"
+    };
+
+    beforeEach(function() {
+      store.requireFeedbackDetails();
+    });
+
+    it("should send feedback data over the feedback client", function() {
+      sandbox.stub(feedbackClient, "send");
+
+      store.sendFeedback(new sharedActions.SendFeedback(sadFeedbackData));
+
+      sinon.assert.calledOnce(feedbackClient.send);
+      sinon.assert.calledWithMatch(feedbackClient.send, sadFeedbackData);
+    });
+
+    it("should transition to PENDING state", function() {
+      sandbox.stub(feedbackClient, "send");
+
+      store.sendFeedback(new sharedActions.SendFeedback(sadFeedbackData));
+
+      expect(store.getStoreState("feedbackState")).eql(FEEDBACK_STATES.PENDING);
+    });
+
+    it("should transition to SENT state on successful submission", function(done) {
+      sandbox.stub(feedbackClient, "send", function(data, cb) {
+        cb(null);
+      });
+
+      store.once("change:feedbackState", function() {
+        expect(store.getStoreState("feedbackState")).eql(FEEDBACK_STATES.SENT);
+        done();
+      });
+
+      store.sendFeedback(new sharedActions.SendFeedback(sadFeedbackData));
+    });
+
+    it("should transition to FAILED state on failed submission", function(done) {
+      sandbox.stub(feedbackClient, "send", function(data, cb) {
+        cb(new Error("failed"));
+      });
+
+      store.once("change:feedbackState", function() {
+        expect(store.getStoreState("feedbackState")).eql(FEEDBACK_STATES.FAILED);
+        done();
+      });
+
+      store.sendFeedback(new sharedActions.SendFeedback(sadFeedbackData));
+    });
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/feedbackViews_test.js
@@ -0,0 +1,209 @@
+/* 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, sinon, React */
+/* jshint newcap:false */
+
+var expect = chai.expect;
+var l10n = navigator.mozL10n || document.mozL10n;
+var TestUtils = React.addons.TestUtils;
+var sharedActions = loop.shared.actions;
+var sharedViews = loop.shared.views;
+
+var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
+
+describe("loop.shared.views.FeedbackView", function() {
+  "use strict";
+
+  var sandbox, comp, dispatcher, feedbackStore, fakeAudioXHR, fakeFeedbackClient;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+    fakeAudioXHR = {
+      open: sinon.spy(),
+      send: function() {},
+      abort: function() {},
+      getResponseHeader: function(header) {
+        if (header === "Content-Type")
+          return "audio/ogg";
+      },
+      responseType: null,
+      response: new ArrayBuffer(10),
+      onload: null
+    };
+    dispatcher = new loop.Dispatcher();
+    fakeFeedbackClient = {send: sandbox.stub()};
+    feedbackStore = new loop.store.FeedbackStore(dispatcher, {
+      feedbackClient: fakeFeedbackClient
+    });
+    sandbox.stub(window, "XMLHttpRequest").returns(fakeAudioXHR);
+    comp = TestUtils.renderIntoDocument(sharedViews.FeedbackView({
+      feedbackStore: feedbackStore
+    }));
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  // local test helpers
+  function clickHappyFace(comp) {
+    var happyFace = comp.getDOMNode().querySelector(".face-happy");
+    TestUtils.Simulate.click(happyFace);
+  }
+
+  function clickSadFace(comp) {
+    var sadFace = comp.getDOMNode().querySelector(".face-sad");
+    TestUtils.Simulate.click(sadFace);
+  }
+
+  function fillSadFeedbackForm(comp, category, text) {
+    TestUtils.Simulate.change(
+      comp.getDOMNode().querySelector("[value='" + category + "']"));
+
+    if (text) {
+      TestUtils.Simulate.change(
+        comp.getDOMNode().querySelector("[name='description']"), {
+          target: {value: "fake reason"}
+        });
+    }
+  }
+
+  function submitSadFeedbackForm(comp, category, text) {
+    TestUtils.Simulate.submit(comp.getDOMNode().querySelector("form"));
+  }
+
+  describe("Happy feedback", function() {
+    it("should dispatch a SendFeedback action", function() {
+      var dispatch = sandbox.stub(dispatcher, "dispatch");
+
+      clickHappyFace(comp);
+
+      sinon.assert.calledWithMatch(dispatch, new sharedActions.SendFeedback({
+        happy: true,
+        category: "",
+        description: ""
+      }));
+    });
+
+    it("should thank the user once feedback data is sent", function() {
+      feedbackStore.setStoreState({feedbackState: FEEDBACK_STATES.SENT});
+
+      expect(comp.getDOMNode().querySelectorAll(".thank-you")).not.eql(null);
+      expect(comp.getDOMNode().querySelector("button.fx-embedded-btn-back"))
+        .eql(null);
+    });
+  });
+
+  describe("Sad feedback", function() {
+    it("should bring the user to feedback form when clicking on the sad face",
+      function() {
+        clickSadFace(comp);
+
+        expect(comp.getDOMNode().querySelectorAll("form")).not.eql(null);
+      });
+
+    it("should render a back button", function() {
+      feedbackStore.setStoreState({feedbackState: FEEDBACK_STATES.DETAILS});
+
+      expect(comp.getDOMNode().querySelector("button.fx-embedded-btn-back"))
+        .not.eql(null);
+    });
+
+    it("should reset the view when clicking the back button", function() {
+      feedbackStore.setStoreState({feedbackState: FEEDBACK_STATES.DETAILS});
+
+      TestUtils.Simulate.click(
+        comp.getDOMNode().querySelector("button.fx-embedded-btn-back"));
+
+      expect(comp.getDOMNode().querySelector(".faces")).not.eql(null);
+    });
+
+    it("should disable the form submit button when no category is chosen",
+      function() {
+        clickSadFace(comp);
+
+        expect(comp.getDOMNode().querySelector("form button").disabled).eql(true);
+      });
+
+    it("should disable the form submit button when the 'other' category is " +
+       "chosen but no description has been entered yet",
+      function() {
+        clickSadFace(comp);
+        fillSadFeedbackForm(comp, "other");
+
+        expect(comp.getDOMNode().querySelector("form button").disabled).eql(true);
+      });
+
+    it("should enable the form submit button when the 'other' category is " +
+       "chosen and a description is entered",
+      function() {
+        clickSadFace(comp);
+        fillSadFeedbackForm(comp, "other", "fake");
+
+        expect(comp.getDOMNode().querySelector("form button").disabled).eql(false);
+      });
+
+    it("should empty the description field when a predefined category is " +
+       "chosen",
+      function() {
+        clickSadFace(comp);
+
+        fillSadFeedbackForm(comp, "confusing");
+
+        expect(comp.getDOMNode().querySelector(".feedback-description").value).eql("");
+      });
+
+    it("should enable the form submit button once a predefined category is " +
+       "chosen",
+      function() {
+        clickSadFace(comp);
+
+        fillSadFeedbackForm(comp, "confusing");
+
+        expect(comp.getDOMNode().querySelector("form button").disabled).eql(false);
+      });
+
+    it("should send feedback data when the form is submitted", function() {
+      var dispatch = sandbox.stub(dispatcher, "dispatch");
+      feedbackStore.setStoreState({feedbackState: FEEDBACK_STATES.DETAILS});
+      fillSadFeedbackForm(comp, "confusing");
+
+      submitSadFeedbackForm(comp);
+
+      sinon.assert.calledOnce(dispatch);
+      sinon.assert.calledWithMatch(dispatch, new sharedActions.SendFeedback({
+        happy: false,
+        category: "confusing",
+        description: ""
+      }));
+    });
+
+    it("should send feedback data when user has entered a custom description",
+      function() {
+        clickSadFace(comp);
+
+        fillSadFeedbackForm(comp, "other", "fake reason");
+        submitSadFeedbackForm(comp);
+
+        sinon.assert.calledOnce(fakeFeedbackClient.send);
+        sinon.assert.calledWith(fakeFeedbackClient.send, {
+          happy: false,
+          category: "other",
+          description: "fake reason"
+        });
+      });
+
+    it("should thank the user when feedback data has been sent", function() {
+      fakeFeedbackClient.send = function(data, cb) {
+        cb();
+      };
+      clickSadFace(comp);
+      fillSadFeedbackForm(comp, "confusing");
+      submitSadFeedbackForm(comp);
+
+      expect(comp.getDOMNode().querySelectorAll(".thank-you")).not.eql(null);
+    });
+  });
+});
--- a/browser/components/loop/test/shared/index.html
+++ b/browser/components/loop/test/shared/index.html
@@ -42,28 +42,32 @@
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
   <script src="../../content/shared/js/otSdkDriver.js"></script>
   <script src="../../content/shared/js/store.js"></script>
   <script src="../../content/shared/js/activeRoomStore.js"></script>
   <script src="../../content/shared/js/roomStore.js"></script>
   <script src="../../content/shared/js/conversationStore.js"></script>
+  <script src="../../content/shared/js/feedbackStore.js"></script>
+  <script src="../../content/shared/js/feedbackViews.js"></script>
 
   <!-- Test scripts -->
   <script src="models_test.js"></script>
   <script src="mixins_test.js"></script>
   <script src="utils_test.js"></script>
   <script src="views_test.js"></script>
   <script src="websocket_test.js"></script>
   <script src="feedbackApiClient_test.js"></script>
+  <script src="feedbackViews_test.js"></script>
   <script src="validate_test.js"></script>
   <script src="dispatcher_test.js"></script>
   <script src="activeRoomStore_test.js"></script>
   <script src="conversationStore_test.js"></script>
+  <script src="feedbackStore_test.js"></script>
   <script src="otSdkDriver_test.js"></script>
   <script src="store_test.js"></script>
   <script src="roomStore_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
   </script>
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -521,187 +521,16 @@ describe("loop.shared.views", function()
 
           expect(comp.state.audio.enabled).eql(false);
           expect(comp.state.video.enabled).eql(false);
         });
       });
     });
   });
 
-  describe("FeedbackView", function() {
-    var comp, fakeFeedbackApiClient;
-
-    beforeEach(function() {
-      fakeFeedbackApiClient = {send: sandbox.stub()};
-      sandbox.stub(window, "XMLHttpRequest").returns(fakeAudioXHR);
-      comp = TestUtils.renderIntoDocument(sharedViews.FeedbackView({
-        feedbackApiClient: fakeFeedbackApiClient
-      }));
-    });
-
-    // local test helpers
-    function clickHappyFace(comp) {
-      var happyFace = comp.getDOMNode().querySelector(".face-happy");
-      TestUtils.Simulate.click(happyFace);
-    }
-
-    function clickSadFace(comp) {
-      var sadFace = comp.getDOMNode().querySelector(".face-sad");
-      TestUtils.Simulate.click(sadFace);
-    }
-
-    function fillSadFeedbackForm(comp, category, text) {
-      TestUtils.Simulate.change(
-        comp.getDOMNode().querySelector("[value='" + category + "']"));
-
-      if (text) {
-        TestUtils.Simulate.change(
-          comp.getDOMNode().querySelector("[name='description']"), {
-            target: {value: "fake reason"}
-          });
-      }
-    }
-
-    function submitSadFeedbackForm(comp, category, text) {
-      TestUtils.Simulate.submit(comp.getDOMNode().querySelector("form"));
-    }
-
-    describe("Happy feedback", function() {
-      it("should send feedback data when clicking on the happy face",
-        function() {
-          clickHappyFace(comp);
-
-          sinon.assert.calledOnce(fakeFeedbackApiClient.send);
-          sinon.assert.calledWith(fakeFeedbackApiClient.send, {happy: true});
-        });
-
-      it("should thank the user once happy feedback data is sent", function() {
-        fakeFeedbackApiClient.send = function(data, cb) {
-          cb();
-        };
-
-        clickHappyFace(comp);
-
-        expect(comp.getDOMNode()
-                   .querySelectorAll(".feedback .thank-you").length).eql(1);
-        expect(comp.getDOMNode().querySelector("button.back")).to.be.a("null");
-      });
-    });
-
-    describe("Sad feedback", function() {
-      it("should bring the user to feedback form when clicking on the sad face",
-        function() {
-          clickSadFace(comp);
-
-          expect(comp.getDOMNode().querySelectorAll("form").length).eql(1);
-        });
-
-      it("should disable the form submit button when no category is chosen",
-        function() {
-          clickSadFace(comp);
-
-          expect(comp.getDOMNode()
-                     .querySelector("form button").disabled).eql(true);
-        });
-
-      it("should disable the form submit button when the 'other' category is " +
-         "chosen but no description has been entered yet",
-        function() {
-          clickSadFace(comp);
-          fillSadFeedbackForm(comp, "other");
-
-          expect(comp.getDOMNode()
-                     .querySelector("form button").disabled).eql(true);
-        });
-
-      it("should enable the form submit button when the 'other' category is " +
-         "chosen and a description is entered",
-        function() {
-          clickSadFace(comp);
-          fillSadFeedbackForm(comp, "other", "fake");
-
-          expect(comp.getDOMNode()
-                     .querySelector("form button").disabled).eql(false);
-        });
-
-      it("should empty the description field when a predefined category is " +
-         "chosen",
-        function() {
-          clickSadFace(comp);
-
-          fillSadFeedbackForm(comp, "confusing");
-
-          expect(comp.getDOMNode()
-                     .querySelector(".feedback-description").value).eql("");
-        });
-
-      it("should enable the form submit button once a predefined category is " +
-         "chosen",
-        function() {
-          clickSadFace(comp);
-
-          fillSadFeedbackForm(comp, "confusing");
-
-          expect(comp.getDOMNode()
-                     .querySelector("form button").disabled).eql(false);
-        });
-
-      it("should disable the form submit button once the form is submitted",
-        function() {
-          clickSadFace(comp);
-          fillSadFeedbackForm(comp, "confusing");
-
-          submitSadFeedbackForm(comp);
-
-          expect(comp.getDOMNode()
-                     .querySelector("form button").disabled).eql(true);
-        });
-
-      it("should send feedback data when the form is submitted", function() {
-        clickSadFace(comp);
-        fillSadFeedbackForm(comp, "confusing");
-
-        submitSadFeedbackForm(comp);
-
-        sinon.assert.calledOnce(fakeFeedbackApiClient.send);
-        sinon.assert.calledWithMatch(fakeFeedbackApiClient.send, {
-          happy: false,
-          category: "confusing"
-        });
-      });
-
-      it("should send feedback data when user has entered a custom description",
-        function() {
-          clickSadFace(comp);
-
-          fillSadFeedbackForm(comp, "other", "fake reason");
-          submitSadFeedbackForm(comp);
-
-          sinon.assert.calledOnce(fakeFeedbackApiClient.send);
-          sinon.assert.calledWith(fakeFeedbackApiClient.send, {
-            happy: false,
-            category: "other",
-            description: "fake reason"
-          });
-        });
-
-      it("should thank the user when feedback data has been sent", function() {
-        fakeFeedbackApiClient.send = function(data, cb) {
-          cb();
-        };
-        clickSadFace(comp);
-        fillSadFeedbackForm(comp, "confusing");
-        submitSadFeedbackForm(comp);
-
-        expect(comp.getDOMNode()
-                   .querySelectorAll(".feedback .thank-you").length).eql(1);
-      });
-    });
-  });
-
   describe("NotificationListView", function() {
     var coll, view, testNotif;
 
     function mountTestComponent(props) {
       return TestUtils.renderIntoDocument(sharedViews.NotificationListView(props));
     }
 
     beforeEach(function() {
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -37,16 +37,18 @@
   <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="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
   <script src="../../content/shared/js/store.js"></script>
   <script src="../../content/shared/js/activeRoomStore.js"></script>
+  <script src="../../content/shared/js/feedbackStore.js"></script>
+  <script src="../../content/shared/js/feedbackViews.js"></script>
   <script src="../../content/shared/js/otSdkDriver.js"></script>
   <script src="../../standalone/content/js/multiplexGum.js"></script>
   <script src="../../standalone/content/js/standaloneAppStore.js"></script>
   <script src="../../standalone/content/js/standaloneClient.js"></script>
   <script src="../../standalone/content/js/standaloneMozLoop.js"></script>
   <script src="../../standalone/content/js/standaloneRoomViews.js"></script>
   <script src="../../standalone/content/js/webapp.js"></script>
   <!-- Test scripts -->
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -14,24 +14,30 @@ describe("loop.webapp", function() {
   var sharedModels = loop.shared.models,
       sharedViews = loop.shared.views,
       sharedUtils = loop.shared.utils,
       standaloneMedia = loop.standaloneMedia,
       sandbox,
       notifications,
       feedbackApiClient,
       stubGetPermsAndCacheMedia,
-      fakeAudioXHR;
+      fakeAudioXHR,
+      dispatcher,
+      feedbackStore;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
+    dispatcher = new loop.Dispatcher();
     notifications = new sharedModels.NotificationCollection();
     feedbackApiClient = new loop.FeedbackAPIClient("http://invalid", {
       product: "Loop"
     });
+    feedbackStore = new loop.store.FeedbackStore(dispatcher, {
+      feedbackClient: {}
+    });
 
     stubGetPermsAndCacheMedia = sandbox.stub(
       loop.standaloneMedia._MultiplexGum.prototype, "getPermsAndCacheMedia");
 
     fakeAudioXHR = {
       open: sinon.spy(),
       send: function() {},
       abort: function() {},
@@ -118,17 +124,17 @@ describe("loop.webapp", function() {
       });
       conversation.set("loopToken", "fakeToken");
       ocView = mountTestComponent({
         helper: new sharedUtils.Helper(),
         client: client,
         conversation: conversation,
         notifications: notifications,
         sdk: {},
-        feedbackApiClient: feedbackApiClient
+        feedbackStore: feedbackStore
       });
     });
 
     describe("start", function() {
       it("should display the StartConversationView", function() {
         TestUtils.findRenderedComponentWithType(ocView,
           loop.webapp.StartConversationView);
       });
@@ -577,17 +583,17 @@ describe("loop.webapp", function() {
         sinon.assert.calledOnce(fakeAudio.play);
         expect(fakeAudio.loop).to.equal(false);
       });
     });
   });
 
   describe("WebappRootView", function() {
     var helper, sdk, conversationModel, client, props, standaloneAppStore;
-    var dispatcher, activeRoomStore;
+    var activeRoomStore;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.webapp.WebappRootView({
         client: client,
         helper: helper,
         notifications: notifications,
         sdk: sdk,
@@ -604,17 +610,16 @@ describe("loop.webapp", function() {
         checkSystemRequirements: function() { return true; }
       };
       conversationModel = new sharedModels.ConversationModel({}, {
         sdk: sdk
       });
       client = new loop.StandaloneClient({
         baseServerUrl: "fakeUrl"
       });
-      dispatcher = new loop.Dispatcher();
       activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
         mozLoop: {},
         sdkDriver: {}
       });
       standaloneAppStore = new loop.store.StandaloneAppStore({
         dispatcher: dispatcher,
         sdk: sdk,
         helper: helper,
@@ -1034,17 +1039,17 @@ describe("loop.webapp", function() {
       conversation = new sharedModels.ConversationModel({}, {
         sdk: {}
       });
       sandbox.stub(window, "XMLHttpRequest").returns(fakeAudioXHR);
       view = React.addons.TestUtils.renderIntoDocument(
         loop.webapp.EndedConversationView({
           conversation: conversation,
           sdk: {},
-          feedbackApiClient: feedbackApiClient,
+          feedbackStore: feedbackStore,
           onAfterFeedbackReceived: function(){}
         })
       );
     });
 
     it("should render a ConversationView", function() {
       TestUtils.findRenderedComponentWithType(view, sharedViews.ConversationView);
     });
--- a/browser/components/loop/ui/index.html
+++ b/browser/components/loop/ui/index.html
@@ -40,16 +40,18 @@
     <script src="../content/shared/js/views.js"></script>
     <script src="../content/shared/js/websocket.js"></script>
     <script src="../content/shared/js/validate.js"></script>
     <script src="../content/shared/js/dispatcher.js"></script>
     <script src="../content/shared/js/store.js"></script>
     <script src="../content/shared/js/roomStore.js"></script>
     <script src="../content/shared/js/conversationStore.js"></script>
     <script src="../content/shared/js/activeRoomStore.js"></script>
+    <script src="../content/shared/js/feedbackStore.js"></script>
+    <script src="../content/shared/js/feedbackViews.js"></script>
     <script src="../content/js/roomViews.js"></script>
     <script src="../content/js/conversationViews.js"></script>
     <script src="../content/js/client.js"></script>
     <script src="../content/js/webapp.js"></script>
     <script src="../content/js/standaloneRoomViews.js"></script>
     <script type="text/javascript;version=1.8" src="../content/js/contacts.js"></script>
     <script>
       if (!loop.contacts) {
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -34,18 +34,19 @@
   var EndedConversationView   = loop.webapp.EndedConversationView;
   var StandaloneRoomView      = loop.standaloneRoomViews.StandaloneRoomView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var ConversationView = loop.shared.views.ConversationView;
   var FeedbackView = loop.shared.views.FeedbackView;
 
-  // Room constants
+  // Store constants
   var ROOM_STATES = loop.store.ROOM_STATES;
+  var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
 
   // Local helpers
   function returnTrue() {
     return true;
   }
 
   function returnFalse() {
     return false;
@@ -64,16 +65,19 @@
   var dispatcher = new loop.Dispatcher();
   var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
     mozLoop: navigator.mozLoop,
     sdkDriver: {}
   });
   var roomStore = new loop.store.RoomStore(dispatcher, {
     mozLoop: navigator.mozLoop
   });
+  var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
+    feedbackClient: stageFeedbackApiClient
+  });
 
   // Local mocks
 
   var mockContact = {
     name: ["Mr Smith"],
     email: [{
       value: "smith@invalid.com"
     }]
@@ -455,23 +459,23 @@
           ), 
 
           Section({name: "FeedbackView"}, 
             React.DOM.p({className: "note"}, 
               React.DOM.strong(null, "Note:"), " For the useable demo, you can access submitted data at ", 
               React.DOM.a({href: "https://input.allizom.org/"}, "input.allizom.org"), "."
             ), 
             Example({summary: "Default (useable demo)", dashed: "true", style: {width: "260px"}}, 
-              FeedbackView({feedbackApiClient: stageFeedbackApiClient})
+              FeedbackView({feedbackStore: feedbackStore})
             ), 
             Example({summary: "Detailed form", dashed: "true", style: {width: "260px"}}, 
-              FeedbackView({feedbackApiClient: stageFeedbackApiClient, step: "form"})
+              FeedbackView({feedbackStore: feedbackStore, feedbackState: FEEDBACK_STATES.DETAILS})
             ), 
             Example({summary: "Thank you!", dashed: "true", style: {width: "260px"}}, 
-              FeedbackView({feedbackApiClient: stageFeedbackApiClient, step: "finished"})
+              FeedbackView({feedbackStore: feedbackStore, feedbackState: FEEDBACK_STATES.SENT})
             )
           ), 
 
           Section({name: "CallUrlExpiredView"}, 
             Example({summary: "Firefox User"}, 
               CallUrlExpiredView({helper: {isFirefox: returnTrue}})
             ), 
             Example({summary: "Non-Firefox User"}, 
@@ -481,17 +485,17 @@
 
           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, 
+                                       feedbackStore: feedbackStore, 
                                        onAfterFeedbackReceived: noop})
               )
             )
           ), 
 
           Section({name: "AlertMessages"}, 
             Example({summary: "Various alerts"}, 
               React.DOM.div({className: "alert alert-warning"}, 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -34,18 +34,19 @@
   var EndedConversationView   = loop.webapp.EndedConversationView;
   var StandaloneRoomView      = loop.standaloneRoomViews.StandaloneRoomView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var ConversationView = loop.shared.views.ConversationView;
   var FeedbackView = loop.shared.views.FeedbackView;
 
-  // Room constants
+  // Store constants
   var ROOM_STATES = loop.store.ROOM_STATES;
+  var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
 
   // Local helpers
   function returnTrue() {
     return true;
   }
 
   function returnFalse() {
     return false;
@@ -64,16 +65,19 @@
   var dispatcher = new loop.Dispatcher();
   var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
     mozLoop: navigator.mozLoop,
     sdkDriver: {}
   });
   var roomStore = new loop.store.RoomStore(dispatcher, {
     mozLoop: navigator.mozLoop
   });
+  var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
+    feedbackClient: stageFeedbackApiClient
+  });
 
   // Local mocks
 
   var mockContact = {
     name: ["Mr Smith"],
     email: [{
       value: "smith@invalid.com"
     }]
@@ -455,23 +459,23 @@
           </Section>
 
           <Section name="FeedbackView">
             <p className="note">
               <strong>Note:</strong> For the useable demo, you can access submitted data at&nbsp;
               <a href="https://input.allizom.org/">input.allizom.org</a>.
             </p>
             <Example summary="Default (useable demo)" dashed="true" style={{width: "260px"}}>
-              <FeedbackView feedbackApiClient={stageFeedbackApiClient} />
+              <FeedbackView feedbackStore={feedbackStore} />
             </Example>
             <Example summary="Detailed form" dashed="true" style={{width: "260px"}}>
-              <FeedbackView feedbackApiClient={stageFeedbackApiClient} step="form" />
+              <FeedbackView feedbackStore={feedbackStore} feedbackState={FEEDBACK_STATES.DETAILS} />
             </Example>
             <Example summary="Thank you!" dashed="true" style={{width: "260px"}}>
-              <FeedbackView feedbackApiClient={stageFeedbackApiClient} step="finished" />
+              <FeedbackView feedbackStore={feedbackStore} feedbackState={FEEDBACK_STATES.SENT} />
             </Example>
           </Section>
 
           <Section name="CallUrlExpiredView">
             <Example summary="Firefox User">
               <CallUrlExpiredView helper={{isFirefox: returnTrue}} />
             </Example>
             <Example summary="Non-Firefox User">
@@ -481,17 +485,17 @@
 
           <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}
+                                       feedbackStore={feedbackStore}
                                        onAfterFeedbackReceived={noop} />
               </div>
             </Example>
           </Section>
 
           <Section name="AlertMessages">
             <Example summary="Various alerts">
               <div className="alert alert-warning">