Bug 1086512 - Added feedback form to Loop desktop room window. r=Standard8
authorNicolas Perriault <nperriault@gmail.com>
Tue, 25 Nov 2014 13:19:34 +0100
changeset 241736 54655536563efff2cda3f2bc22dd9effd28dfa2e
parent 241735 6f22248a31952d2c30bd6096be7b2ddb620ace2c
child 241737 3e39b5ca01ee4d0bb2b5db6b41efe5018f95efce
push id4311
push userraliiev@mozilla.com
push dateMon, 12 Jan 2015 19:37:41 +0000
treeherdermozilla-beta@150c9fed433b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs1086512
milestone36.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1086512 - Added feedback form to Loop desktop room window. r=Standard8
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/js/roomViews.js
browser/components/loop/content/js/roomViews.jsx
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/content/shared/img/icons-16x16.svg
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/content/shared/js/feedbackViews.js
browser/components/loop/content/shared/js/feedbackViews.jsx
browser/components/loop/content/shared/js/mixins.js
browser/components/loop/standalone/content/js/standaloneRoomViews.js
browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/roomViews_test.js
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/shared/feedbackViews_test.js
browser/components/loop/test/standalone/standaloneRoomViews_test.js
browser/components/loop/test/standalone/webapp_test.js
browser/components/loop/ui/fake-mozLoop.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -218,24 +218,27 @@ loop.conversation = (function(mozL10n) {
 
   /**
    * This view manages the incoming conversation views - from
    * call initiation through to the actual conversation and call end.
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var IncomingConversationView = React.createClass({displayName: 'IncomingConversationView',
+    mixins: [sharedMixins.AudioMixin],
+
     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,
-      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
+      feedbackStore:
+        React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired
     },
 
     getInitialState: function() {
       return {
         callFailed: false, // XXX this should be removed when bug 1047410 lands.
         callStatus: "start"
       };
     },
@@ -297,16 +300,18 @@ loop.conversation = (function(mozL10n) {
           if (this.state.callFailed) {
             return GenericFailureView({
               cancelCall: this.closeWindow.bind(this)}
             );
           }
 
           document.title = mozL10n.get("conversation_has_ended");
 
+          this.play("terminated");
+
           return (
             sharedViews.FeedbackView({
               feedbackStore: this.props.feedbackStore, 
               onAfterFeedbackReceived: this.closeWindow.bind(this)}
             )
           );
         }
         case "close": {
@@ -547,17 +552,18 @@ loop.conversation = (function(mozL10n) {
 
       // 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),
-      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
+      feedbackStore:
+        React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired
     },
 
     getInitialState: function() {
       return this.props.conversationAppStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.conversationAppStore, "change", function() {
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -218,24 +218,27 @@ loop.conversation = (function(mozL10n) {
 
   /**
    * This view manages the incoming conversation views - from
    * call initiation through to the actual conversation and call end.
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var IncomingConversationView = React.createClass({
+    mixins: [sharedMixins.AudioMixin],
+
     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,
-      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
+      feedbackStore:
+        React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired
     },
 
     getInitialState: function() {
       return {
         callFailed: false, // XXX this should be removed when bug 1047410 lands.
         callStatus: "start"
       };
     },
@@ -297,16 +300,18 @@ loop.conversation = (function(mozL10n) {
           if (this.state.callFailed) {
             return <GenericFailureView
               cancelCall={this.closeWindow.bind(this)}
             />;
           }
 
           document.title = mozL10n.get("conversation_has_ended");
 
+          this.play("terminated");
+
           return (
             <sharedViews.FeedbackView
               feedbackStore={this.props.feedbackStore}
               onAfterFeedbackReceived={this.closeWindow.bind(this)}
             />
           );
         }
         case "close": {
@@ -547,17 +552,18 @@ loop.conversation = (function(mozL10n) {
 
       // 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),
-      feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
+      feedbackStore:
+        React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired
     },
 
     getInitialState: function() {
       return this.props.conversationAppStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.conversationAppStore, "change", function() {
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -423,16 +423,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',
+    mixins: [sharedMixins.AudioMixin],
+
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       store: React.PropTypes.instanceOf(
         loop.store.ConversationStore).isRequired,
       feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
@@ -488,16 +490,17 @@ loop.conversationViews = (function(mozL1
           return (OngoingConversationView({
             dispatcher: this.props.dispatcher, 
             video: {enabled: !this.state.videoMuted}, 
             audio: {enabled: !this.state.audioMuted}}
             )
           );
         }
         case CALL_STATES.FINISHED: {
+          this.play("terminated");
           return this._renderFeedbackView();
         }
         case CALL_STATES.INIT: {
           // We know what we are, but we haven't got the data yet.
           return null;
         }
         default: {
           return (PendingConversationView({
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -423,16 +423,18 @@ loop.conversationViews = (function(mozL1
     }
   });
 
   /**
    * Master View Controller for outgoing calls. This manages
    * the different views that need displaying.
    */
   var OutgoingConversationView = React.createClass({
+    mixins: [sharedMixins.AudioMixin],
+
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       store: React.PropTypes.instanceOf(
         loop.store.ConversationStore).isRequired,
       feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
     },
 
     getInitialState: function() {
@@ -488,16 +490,17 @@ loop.conversationViews = (function(mozL1
           return (<OngoingConversationView
             dispatcher={this.props.dispatcher}
             video={{enabled: !this.state.videoMuted}}
             audio={{enabled: !this.state.audioMuted}}
             />
           );
         }
         case CALL_STATES.FINISHED: {
+          this.play("terminated");
           return this._renderFeedbackView();
         }
         case CALL_STATES.INIT: {
           // We know what we are, but we haven't got the data yet.
           return null;
         }
         default: {
           return (<PendingConversationView
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -11,18 +11,16 @@ var loop = loop || {};
 loop.roomViews = (function(mozL10n) {
   "use strict";
 
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var sharedViews = loop.shared.views;
 
-  function noop() {}
-
   /**
    * ActiveRoomStore mixin.
    * @type {Object}
    */
   var ActiveRoomStoreMixin = {
     mixins: [Backbone.Events],
 
     propTypes: {
@@ -135,17 +133,19 @@ loop.roomViews = (function(mozL10n) {
   var DesktopRoomConversationView = React.createClass({displayName: 'DesktopRoomConversationView',
     mixins: [
       ActiveRoomStoreMixin,
       sharedMixins.DocumentTitleMixin,
       sharedMixins.RoomsAudioMixin
     ],
 
     propTypes: {
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      feedbackStore:
+        React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired,
     },
 
     _renderInvitationOverlay: function() {
       if (this.state.roomState !== ROOM_STATES.HAS_PARTICIPANTS) {
         return DesktopRoomInvitationView({
           roomStore: this.props.roomStore, 
           dispatcher: this.props.dispatcher}
         );
@@ -211,16 +211,23 @@ loop.roomViews = (function(mozL10n) {
      *
      * @param {String} className The name of the class to get the element for.
      */
     _getElement: function(className) {
       return this.getDOMNode().querySelector(className);
     },
 
     /**
+     * User clicked on the "Leave" button.
+     */
+    leaveRoom: function() {
+      this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
+    },
+
+    /**
      * Closes the window if the cancel button is pressed in the generic failure view.
      */
     closeWindow: function() {
       window.close();
     },
 
     /**
      * Used to control publishing a stream - i.e. to mute a stream
@@ -252,33 +259,39 @@ loop.roomViews = (function(mozL10n) {
         case ROOM_STATES.FAILED:
         case ROOM_STATES.FULL: {
           // Note: While rooms are set to hold a maximum of 2 participants, the
           //       FULL case should never happen on desktop.
           return loop.conversation.GenericFailureView({
             cancelCall: this.closeWindow}
           );
         }
+        case ROOM_STATES.ENDED: {
+          return sharedViews.FeedbackView({
+            feedbackStore: this.props.feedbackStore, 
+            onAfterFeedbackReceived: this.closeWindow}
+          );
+        }
         default: {
           return (
             React.DOM.div({className: "room-conversation-wrapper"}, 
               this._renderInvitationOverlay(), 
               React.DOM.div({className: "video-layout-wrapper"}, 
                 React.DOM.div({className: "conversation room-conversation"}, 
                   React.DOM.div({className: "media nested"}, 
                     React.DOM.div({className: "video_wrapper remote_wrapper"}, 
                       React.DOM.div({className: "video_inner remote"})
                     ), 
                     React.DOM.div({className: localStreamClasses})
                   ), 
                   sharedViews.ConversationToolbar({
                     video: {enabled: !this.state.videoMuted, visible: true}, 
                     audio: {enabled: !this.state.audioMuted, visible: true}, 
                     publishStream: this.publishStream, 
-                    hangup: noop})
+                    hangup: this.leaveRoom})
                 )
               )
             )
           );
         }
       }
     }
   });
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -11,18 +11,16 @@ var loop = loop || {};
 loop.roomViews = (function(mozL10n) {
   "use strict";
 
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var sharedViews = loop.shared.views;
 
-  function noop() {}
-
   /**
    * ActiveRoomStore mixin.
    * @type {Object}
    */
   var ActiveRoomStoreMixin = {
     mixins: [Backbone.Events],
 
     propTypes: {
@@ -135,17 +133,19 @@ loop.roomViews = (function(mozL10n) {
   var DesktopRoomConversationView = React.createClass({
     mixins: [
       ActiveRoomStoreMixin,
       sharedMixins.DocumentTitleMixin,
       sharedMixins.RoomsAudioMixin
     ],
 
     propTypes: {
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      feedbackStore:
+        React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired,
     },
 
     _renderInvitationOverlay: function() {
       if (this.state.roomState !== ROOM_STATES.HAS_PARTICIPANTS) {
         return <DesktopRoomInvitationView
           roomStore={this.props.roomStore}
           dispatcher={this.props.dispatcher}
         />;
@@ -211,16 +211,23 @@ loop.roomViews = (function(mozL10n) {
      *
      * @param {String} className The name of the class to get the element for.
      */
     _getElement: function(className) {
       return this.getDOMNode().querySelector(className);
     },
 
     /**
+     * User clicked on the "Leave" button.
+     */
+    leaveRoom: function() {
+      this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
+    },
+
+    /**
      * Closes the window if the cancel button is pressed in the generic failure view.
      */
     closeWindow: function() {
       window.close();
     },
 
     /**
      * Used to control publishing a stream - i.e. to mute a stream
@@ -252,33 +259,39 @@ loop.roomViews = (function(mozL10n) {
         case ROOM_STATES.FAILED:
         case ROOM_STATES.FULL: {
           // Note: While rooms are set to hold a maximum of 2 participants, the
           //       FULL case should never happen on desktop.
           return <loop.conversation.GenericFailureView
             cancelCall={this.closeWindow}
           />;
         }
+        case ROOM_STATES.ENDED: {
+          return <sharedViews.FeedbackView
+            feedbackStore={this.props.feedbackStore}
+            onAfterFeedbackReceived={this.closeWindow}
+          />;
+        }
         default: {
           return (
             <div className="room-conversation-wrapper">
               {this._renderInvitationOverlay()}
               <div className="video-layout-wrapper">
                 <div className="conversation room-conversation">
                   <div className="media nested">
                     <div className="video_wrapper remote_wrapper">
                       <div className="video_inner remote"></div>
                     </div>
                     <div className={localStreamClasses}></div>
                   </div>
                   <sharedViews.ConversationToolbar
                     video={{enabled: !this.state.videoMuted, visible: true}}
                     audio={{enabled: !this.state.audioMuted, visible: true}}
                     publishStream={this.publishStream}
-                    hangup={noop} />
+                    hangup={this.leaveRoom} />
                 </div>
               </div>
             </div>
           );
         }
       }
     }
   });
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -739,21 +739,18 @@ html, .fx-embedded, #main,
   height: 60px;
   margin-top: -12px;
 }
 
 .room-conversation-wrapper footer a {
   color: #555;
 }
 
-/**
- * Hides the hangup button for room conversations.
- */
-.room-conversation .conversation-toolbar .btn-hangup-entry {
-  display: none;
+.fx-embedded .room-conversation .conversation-toolbar .btn-hangup {
+  background-image: url("../img/icons-16x16.svg#leave");
 }
 
 .room-invitation-overlay {
   position: absolute;
   background: rgba(0, 0, 0, .6);
   top: 0;
   right: 0;
   bottom: 0;
--- a/browser/components/loop/content/shared/img/icons-16x16.svg
+++ b/browser/components/loop/content/shared/img/icons-16x16.svg
@@ -113,16 +113,20 @@ use[id$="-red"] {
       c0.054,0.585,0.543,1.044,1.114,1.044h3.38c0.57,0,1.06-0.458,1.114-1.043l0.509-5.439H12V5.79z M6.407,4.264V4.165
       c0-0.375,0.271-0.678,0.606-0.678h1.974c0.334,0,0.606,0.304,0.606,0.678v0.099c0,0.063-0.01,0.123-0.025,0.181H6.432
       C6.417,4.387,6.407,4.328,6.407,4.264z M10.057,12.056c-0.019,0.197-0.188,0.363-0.368,0.363h-3.38
       c-0.182,0-0.35-0.166-0.368-0.363L5.44,6.687h5.12L10.057,12.056z"/>
     <rect x="7.75" y="7.542" fill="#FFFFFF" width="0.5" height="4"/>
     <polyline fill="#FFFFFF" points="9.25,7.542 8.75,7.542 8.75,11.542 9.25,11.542  "/>
     <rect x="6.75" y="7.542" fill="#FFFFFF" width="0.5" height="4"/>
   </g>
+  <g id="leave-shape">
+    <polygon fill="#FFFFFF" points="2.08,11.52 2.08,4 8,4 8,2.24 0.32,2.24 0.32,13.28 8,13.28 8,11.52"/>
+    <polygon fill="#FFFFFF" points="15.66816,7.77344 9.6,2.27456 9.6,5.6 3.68,5.6 3.68,9.92 9.6,9.92 9.6,13.27232"/>
+  </g>
 </defs>
 <use id="audio"               xlink:href="#audio-shape"/>
 <use id="audio-hover"         xlink:href="#audio-shape"/>
 <use id="audio-active"        xlink:href="#audio-shape"/>
 <use id="block"               xlink:href="#block-shape"/>
 <use id="block-red"           xlink:href="#block-shape"/>
 <use id="block-hover"         xlink:href="#block-shape"/>
 <use id="block-active"        xlink:href="#block-shape"/>
@@ -132,16 +136,17 @@ use[id$="-red"] {
 <use id="copy"                xlink:href="#copy-shape"/>
 <use id="checkmark"           xlink:href="#checkmark-shape"/>
 <use id="google"              xlink:href="#google-shape"/>
 <use id="google-hover"        xlink:href="#google-shape"/>
 <use id="google-active"       xlink:href="#google-shape"/>
 <use id="history"             xlink:href="#history-shape"/>
 <use id="history-hover"       xlink:href="#history-shape"/>
 <use id="history-active"      xlink:href="#history-shape"/>
+<use id="leave"               xlink:href="#leave-shape"/>
 <use id="precall"             xlink:href="#precall-shape"/>
 <use id="precall-hover"       xlink:href="#precall-shape"/>
 <use id="precall-active"      xlink:href="#precall-shape"/>
 <use id="settings"            xlink:href="#settings-shape"/>
 <use id="settings-hover"      xlink:href="#settings-shape"/>
 <use id="settings-active"     xlink:href="#settings-shape"/>
 <use id="tag"                 xlink:href="#tag-shape"/>
 <use id="tag-hover"           xlink:href="#tag-shape"/>
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -32,17 +32,19 @@ loop.store.ActiveRoomStore = (function()
     JOINED: "room-joined",
     // The room is connected to the sdk server.
     SESSION_CONNECTED: "room-session-connected",
     // There are participants in the room.
     HAS_PARTICIPANTS: "room-has-participants",
     // There was an issue with the room
     FAILED: "room-failed",
     // The room is full
-    FULL: "room-full"
+    FULL: "room-full",
+    // The room conversation has ended
+    ENDED: "room-ended"
   };
 
   /**
    * Active room store.
    *
    * @param {loop.Dispatcher} dispatcher  The dispatcher for dispatching actions
    *                                      and registering to consume actions.
    * @param {Object} options Options object:
@@ -419,15 +421,15 @@ loop.store.ActiveRoomStore = (function()
       if (this._storeState.roomState === ROOM_STATES.JOINED ||
           this._storeState.roomState === ROOM_STATES.SESSION_CONNECTED ||
           this._storeState.roomState === ROOM_STATES.HAS_PARTICIPANTS) {
         this._mozLoop.rooms.leave(this._storeState.roomToken,
           this._storeState.sessionToken);
       }
 
       this.setStoreState({
-        roomState: nextState ? nextState : ROOM_STATES.READY
+        roomState: nextState ? nextState : ROOM_STATES.ENDED
       });
     }
   });
 
   return ActiveRoomStore;
 })();
--- a/browser/components/loop/content/shared/js/feedbackViews.js
+++ b/browser/components/loop/content/shared/js/feedbackViews.js
@@ -217,17 +217,17 @@ loop.shared.views.FeedbackView = (functi
       );
     }
   });
 
   /**
    * Feedback view.
    */
   var FeedbackView = React.createClass({displayName: 'FeedbackView',
-    mixins: [Backbone.Events, sharedMixins.AudioMixin],
+    mixins: [Backbone.Events],
 
     propTypes: {
       feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
       onAfterFeedbackReceived: React.PropTypes.func,
       // Used by the UI showcase.
       feedbackState: React.PropTypes.string
     },
 
@@ -237,20 +237,16 @@ loop.shared.views.FeedbackView = (functi
         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());
     },
 
--- a/browser/components/loop/content/shared/js/feedbackViews.jsx
+++ b/browser/components/loop/content/shared/js/feedbackViews.jsx
@@ -217,17 +217,17 @@ loop.shared.views.FeedbackView = (functi
       );
     }
   });
 
   /**
    * Feedback view.
    */
   var FeedbackView = React.createClass({
-    mixins: [Backbone.Events, sharedMixins.AudioMixin],
+    mixins: [Backbone.Events],
 
     propTypes: {
       feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
       onAfterFeedbackReceived: React.PropTypes.func,
       // Used by the UI showcase.
       feedbackState: React.PropTypes.string
     },
 
@@ -237,20 +237,16 @@ loop.shared.views.FeedbackView = (functi
         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());
     },
 
--- a/browser/components/loop/content/shared/js/mixins.js
+++ b/browser/components/loop/content/shared/js/mixins.js
@@ -233,27 +233,28 @@ loop.shared.mixins = (function() {
   var RoomsAudioMixin = {
     mixins: [AudioMixin],
 
     componentWillUpdate: function(nextProps, nextState) {
       var ROOM_STATES = loop.store.ROOM_STATES;
 
       function isConnectedToRoom(state) {
         return state === ROOM_STATES.HAS_PARTICIPANTS ||
-          state === ROOM_STATES.SESSION_CONNECTED;
+               state === ROOM_STATES.SESSION_CONNECTED;
       }
 
       function notConnectedToRoom(state) {
         // Failed and full are states that the user is not
-        // really connected o the room, but we don't want to
+        // really connected to the room, but we don't want to
         // catch those here, as they get their own sounds.
         return state === ROOM_STATES.INIT ||
-          state === ROOM_STATES.GATHER ||
-          state === ROOM_STATES.READY ||
-          state === ROOM_STATES.JOINED;
+               state === ROOM_STATES.GATHER ||
+               state === ROOM_STATES.READY ||
+               state === ROOM_STATES.JOINED ||
+               state === ROOM_STATES.ENDED;
       }
 
       // Joining the room.
       if (notConnectedToRoom(this.state.roomState) &&
           isConnectedToRoom(nextState.roomState)) {
         this.play("room-joined");
       }
 
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -47,23 +47,25 @@ loop.standaloneRoomViews = (function(moz
     _getFailureString: function() {
       switch(this.props.failureReason) {
         case FAILURE_REASONS.MEDIA_DENIED:
           return mozL10n.get("rooms_media_denied_message");
         case FAILURE_REASONS.EXPIRED_OR_INVALID:
           return mozL10n.get("rooms_unavailable_notification_message");
         default:
           return mozL10n.get("status_error");
-      };
+      }
     },
 
     _renderContent: function() {
       switch(this.props.roomState) {
         case ROOM_STATES.INIT:
-        case ROOM_STATES.READY: {
+        case ROOM_STATES.READY:
+        case ROOM_STATES.ENDED: {
+          // XXX: In ENDED state, we should rather display the feedback form.
           return (
             React.DOM.button({className: "btn btn-join btn-info", 
                     onClick: this.props.joinRoom}, 
               mozL10n.get("rooms_room_join_label")
             )
           );
         }
         case ROOM_STATES.JOINED:
@@ -214,23 +216,24 @@ loop.standaloneRoomViews = (function(moz
       document.body.classList.add("is-standalone-room");
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.activeRoomStore);
     },
 
     /**
-     * Watches for when we transition from READY to JOINED room state, so we can
-     * request user media access.
+     * Watches for when we transition to JOINED room state, so we can request
+     * user media access.
+     *
      * @param  {Object} nextProps (Unused)
      * @param  {Object} nextState Next state object.
      */
     componentWillUpdate: function(nextProps, nextState) {
-      if (this.state.roomState === ROOM_STATES.READY &&
+      if (this.state.roomState !== ROOM_STATES.JOINED &&
           nextState.roomState === ROOM_STATES.JOINED) {
         this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
           publisherConfig: this._getPublisherConfig(),
           getLocalElementFunc: this._getElement.bind(this, ".local"),
           getRemoteElementFunc: this._getElement.bind(this, ".remote")
         }));
       }
     },
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -47,23 +47,25 @@ loop.standaloneRoomViews = (function(moz
     _getFailureString: function() {
       switch(this.props.failureReason) {
         case FAILURE_REASONS.MEDIA_DENIED:
           return mozL10n.get("rooms_media_denied_message");
         case FAILURE_REASONS.EXPIRED_OR_INVALID:
           return mozL10n.get("rooms_unavailable_notification_message");
         default:
           return mozL10n.get("status_error");
-      };
+      }
     },
 
     _renderContent: function() {
       switch(this.props.roomState) {
         case ROOM_STATES.INIT:
-        case ROOM_STATES.READY: {
+        case ROOM_STATES.READY:
+        case ROOM_STATES.ENDED: {
+          // XXX: In ENDED state, we should rather display the feedback form.
           return (
             <button className="btn btn-join btn-info"
                     onClick={this.props.joinRoom}>
               {mozL10n.get("rooms_room_join_label")}
             </button>
           );
         }
         case ROOM_STATES.JOINED:
@@ -214,23 +216,24 @@ loop.standaloneRoomViews = (function(moz
       document.body.classList.add("is-standalone-room");
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.activeRoomStore);
     },
 
     /**
-     * Watches for when we transition from READY to JOINED room state, so we can
-     * request user media access.
+     * Watches for when we transition to JOINED room state, so we can request
+     * user media access.
+     *
      * @param  {Object} nextProps (Unused)
      * @param  {Object} nextState Next state object.
      */
     componentWillUpdate: function(nextProps, nextState) {
-      if (this.state.roomState === ROOM_STATES.READY &&
+      if (this.state.roomState !== ROOM_STATES.JOINED &&
           nextState.roomState === ROOM_STATES.JOINED) {
         this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
           publisherConfig: this._getPublisherConfig(),
           getLocalElementFunc: this._getElement.bind(this, ".local"),
           getRemoteElementFunc: this._getElement.bind(this, ".remote")
         }));
       }
     },
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -505,16 +505,32 @@ describe("loop.conversationViews", funct
         store.set({callState: CALL_STATES.FINISHED});
 
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           loop.shared.views.FeedbackView);
     });
 
+    it("should play the terminated sound when the call state is 'finished'",
+      function() {
+        var fakeAudio = {
+          play: sinon.spy(),
+          pause: sinon.spy(),
+          removeAttribute: sinon.spy()
+        };
+        sandbox.stub(window, "Audio").returns(fakeAudio);
+
+        store.set({callState: CALL_STATES.FINISHED});
+
+        view = mountTestComponent();
+
+        sinon.assert.calledOnce(fakeAudio.play);
+    });
+
     it("should update the rendered views when the state is changed.",
       function() {
         store.set({
           callState: CALL_STATES.GATHER,
           contact: contact
         });
 
         view = mountTestComponent();
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -194,17 +194,20 @@ describe("loop.roomViews", function () {
     beforeEach(function() {
       sandbox.stub(dispatcher, "dispatch");
     });
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         new loop.roomViews.DesktopRoomConversationView({
           dispatcher: dispatcher,
-          roomStore: roomStore
+          roomStore: roomStore,
+          feedbackStore: new loop.store.FeedbackStore(dispatcher, {
+            feedbackClient: {}
+          })
         }));
     }
 
     it("should dispatch a setupStreamElements action when the view is created",
       function() {
         view = mountTestComponent();
 
         sinon.assert.calledOnce(dispatcher.dispatch);
@@ -311,11 +314,21 @@ describe("loop.roomViews", function () {
         function() {
           activeRoomStore.setStoreState({roomState: ROOM_STATES.HAS_PARTICIPANTS});
 
           view = mountTestComponent();
 
           TestUtils.findRenderedComponentWithType(view,
             loop.roomViews.DesktopRoomConversationView);
         });
+
+      it("should render the FeedbackView if roomState is `ENDED`",
+        function() {
+          activeRoomStore.setStoreState({roomState: ROOM_STATES.ENDED});
+
+          view = mountTestComponent();
+
+          TestUtils.findRenderedComponentWithType(view,
+            loop.shared.views.FeedbackView);
+        });
     });
   });
 });
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -569,20 +569,20 @@ describe("loop.store.ActiveRoomStore", f
     it("should call mozLoop.rooms.leave", function() {
       store.windowUnload();
 
       sinon.assert.calledOnce(fakeMozLoop.rooms.leave);
       sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave,
         "fakeToken", "1627384950");
     });
 
-    it("should set the state to ready", function() {
+    it("should set the state to ENDED", function() {
       store.windowUnload();
 
-      expect(store._storeState.roomState).eql(ROOM_STATES.READY);
+      expect(store._storeState.roomState).eql(ROOM_STATES.ENDED);
     });
   });
 
   describe("#leaveRoom", function() {
     beforeEach(function() {
       store.setStoreState({
         roomState: ROOM_STATES.JOINED,
         roomToken: "fakeToken",
@@ -614,20 +614,20 @@ describe("loop.store.ActiveRoomStore", f
     it("should call mozLoop.rooms.leave", function() {
       store.leaveRoom();
 
       sinon.assert.calledOnce(fakeMozLoop.rooms.leave);
       sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave,
         "fakeToken", "1627384950");
     });
 
-    it("should set the state to ready", function() {
+    it("should set the state to ENDED", function() {
       store.leaveRoom();
 
-      expect(store._storeState.roomState).eql(ROOM_STATES.READY);
+      expect(store._storeState.roomState).eql(ROOM_STATES.ENDED);
     });
   });
 
   describe("Events", function() {
     describe("update:{roomToken}", function() {
       beforeEach(function() {
         store.setupRoomInfo(new sharedActions.SetupRoomInfo({
           roomName: "Its a room",
--- a/browser/components/loop/test/shared/feedbackViews_test.js
+++ b/browser/components/loop/test/shared/feedbackViews_test.js
@@ -11,38 +11,25 @@ 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;
+  var sandbox, comp, dispatcher, fakeFeedbackClient, feedbackStore;
 
   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();
   });
--- a/browser/components/loop/test/standalone/standaloneRoomViews_test.js
+++ b/browser/components/loop/test/standalone/standaloneRoomViews_test.js
@@ -33,37 +33,52 @@ describe("loop.standaloneRoomViews", fun
       return TestUtils.renderIntoDocument(
         loop.standaloneRoomViews.StandaloneRoomView({
           dispatcher: dispatcher,
           activeRoomStore: activeRoomStore,
           helper: new loop.shared.utils.Helper()
         }));
     }
 
-    describe("#componentWillUpdate", function() {
-      it("dispatch an `SetupStreamElements` action on room joined", function() {
-        activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
-        var view = mountTestComponent();
-
-        sinon.assert.notCalled(dispatch);
-
-        activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
+    function expectActionDispatched(view) {
+      sinon.assert.calledOnce(dispatch);
+      sinon.assert.calledWithExactly(dispatch,
+        sinon.match.instanceOf(sharedActions.SetupStreamElements));
+      sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
+        return value.getLocalElementFunc() ===
+               view.getDOMNode().querySelector(".local");
+      }));
+      sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
+        return value.getRemoteElementFunc() ===
+               view.getDOMNode().querySelector(".remote");
+      }));
+    }
 
-        sinon.assert.calledOnce(dispatch);
-        sinon.assert.calledWithExactly(dispatch,
-          sinon.match.instanceOf(sharedActions.SetupStreamElements));
-        sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
-          return value.getLocalElementFunc() ===
-                 view.getDOMNode().querySelector(".local");
-        }));
-        sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
-          return value.getRemoteElementFunc() ===
-                 view.getDOMNode().querySelector(".remote");
-        }));
-      });
+    describe("#componentWillUpdate", function() {
+      it("should dispatch a `SetupStreamElements` action on room joined",
+        function() {
+          activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
+          var view = mountTestComponent();
+
+          sinon.assert.notCalled(dispatch);
+
+          activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
+
+          expectActionDispatched(view);
+        });
+
+      it("should dispatch a `SetupStreamElements` action on room rejoined",
+        function() {
+          activeRoomStore.setStoreState({roomState: ROOM_STATES.ENDED});
+          var view = mountTestComponent();
+
+          activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
+
+          expectActionDispatched(view);
+        });
     });
 
     describe("#publishStream", function() {
       var view;
 
       beforeEach(function() {
         view = mountTestComponent();
         view.setState({
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -1052,32 +1052,16 @@ describe("loop.webapp", function() {
 
     it("should render a ConversationView", function() {
       TestUtils.findRenderedComponentWithType(view, sharedViews.ConversationView);
     });
 
     it("should render a FeedbackView", function() {
       TestUtils.findRenderedComponentWithType(view, sharedViews.FeedbackView);
     });
-
-    describe("#componentDidMount", function() {
-
-      it("should play a terminating sound, once", function() {
-        fakeAudioXHR.onload();
-
-        sinon.assert.called(fakeAudioXHR.open);
-        sinon.assert.calledWithExactly(
-          fakeAudioXHR.open, "GET", "shared/sounds/terminated.ogg", true);
-
-        sinon.assert.calledOnce(fakeAudio.play);
-        expect(fakeAudio.loop).to.not.equal(true);
-      });
-
-    });
-
   });
 
   describe("PromoteFirefoxView", function() {
     describe("#render", function() {
       it("should not render when using Firefox", function() {
         var comp = TestUtils.renderIntoDocument(loop.webapp.PromoteFirefoxView({
           helper: {isFirefox: function() { return true; }}
         }));
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -44,22 +44,24 @@ var fakeRooms = [
 ];
 
 /**
  * Faking the mozLoop object which doesn't exist in regular web pages.
  * @type {Object}
  */
 navigator.mozLoop = {
   ensureRegistered: function() {},
+  getAudioBlob: function(){},
   getLoopPref: function(pref) {
     // Ensure UI for rooms is displayed in the showcase.
     if (pref === "rooms.enabled") {
       return true;
     }
   },
+  setLoopPref: function(){},
   releaseCallData: function() {},
   copyString: function() {},
   contacts: {
     getAll: function(callback) {
       callback(null, []);
     },
     on: function() {}
   },
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -123,17 +123,17 @@
     }
   });
 
   var SVGIcons = React.createClass({displayName: 'SVGIcons',
     shapes: [
       "audio", "audio-hover", "audio-active", "block",
       "block-red", "block-hover", "block-active", "contacts", "contacts-hover",
       "contacts-active", "copy", "checkmark", "google", "google-hover",
-      "google-active", "history", "history-hover", "history-active",
+      "google-active", "history", "history-hover", "history-active", "leave",
       "precall", "precall-hover", "precall-active", "settings", "settings-hover",
       "settings-active", "tag", "tag-hover", "tag-active", "trash", "unblock",
       "unblock-hover", "unblock-active", "video", "video-hover", "video-active"
     ],
 
     render: function() {
       return (
         React.DOM.div({className: "svg-icon-list"}, 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -123,17 +123,17 @@
     }
   });
 
   var SVGIcons = React.createClass({
     shapes: [
       "audio", "audio-hover", "audio-active", "block",
       "block-red", "block-hover", "block-active", "contacts", "contacts-hover",
       "contacts-active", "copy", "checkmark", "google", "google-hover",
-      "google-active", "history", "history-hover", "history-active",
+      "google-active", "history", "history-hover", "history-active", "leave",
       "precall", "precall-hover", "precall-active", "settings", "settings-hover",
       "settings-active", "tag", "tag-hover", "tag-active", "trash", "unblock",
       "unblock-hover", "unblock-active", "video", "video-hover", "video-active"
     ],
 
     render: function() {
       return (
         <div className="svg-icon-list">{