Bug 1086512 - Added feedback form to Loop desktop room window. r=Standard8 a=lsblakk
authorNicolas Perriault <nperriault@gmail.com>
Tue, 25 Nov 2014 13:19:34 +0100
changeset 226309 7c5884414d69
parent 226308 ce8e174af2f2
child 226310 390a34a40ea4
push id7324
push userrjesup@wgate.com
push dateFri, 28 Nov 2014 04:37:05 +0000
treeherdermozilla-aurora@390a34a40ea4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8, lsblakk
bugs1086512
milestone35.0a2
Bug 1086512 - Added feedback form to Loop desktop room window. r=Standard8 a=lsblakk
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">{