Bug 1180179 - Part 3. Use the shared media layout component in direct calls. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Mon, 27 Jul 2015 12:45:47 +0200
changeset 280953 245aec9f19a22fe517af46f3db8fc2e1e865ed17
parent 280952 4eb157aac5859accf4505512fcb80be0d4dcf712
child 280954 0ccdbc708362b19f8656f3c2429675b661b43e5d
push id3812
push userj.parkouss@gmail.com
push dateMon, 27 Jul 2015 16:03:08 +0000
reviewersmikedeboer
bugs1180179
milestone42.0a1
Bug 1180179 - Part 3. Use the shared media layout component in direct calls. r=mikedeboer
browser/components/loop/content/js/conversationViews.js
browser/components/loop/content/js/conversationViews.jsx
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/content/shared/js/textChatView.js
browser/components/loop/content/shared/js/textChatView.jsx
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
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/shared/textChatView_test.js
browser/components/loop/test/shared/views_test.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
browser/locales/en-US/chrome/browser/loop/loop.properties
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -565,23 +565,25 @@ loop.conversationViews = (function(mozL1
           )
         )
       );
     }
   });
 
   var OngoingConversationView = React.createClass({displayName: "OngoingConversationView",
     mixins: [
-      loop.store.StoreMixin("conversationStore"),
       sharedMixins.MediaSetupMixin
     ],
 
     propTypes: {
       // local
       audio: React.PropTypes.object,
+      // We pass conversationStore here rather than use the mixin, to allow
+      // easy configurability for the ui-showcase.
+      conversationStore: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // The poster URLs are for UI-showcase testing and development.
       localPosterUrl: React.PropTypes.string,
       // This is used from the props rather than the state to make it easier for
       // the ui-showcase.
       mediaConnected: React.PropTypes.bool,
       remotePosterUrl: React.PropTypes.string,
       remoteVideoEnabled: React.PropTypes.bool,
@@ -592,17 +594,27 @@ loop.conversationViews = (function(mozL1
     getDefaultProps: function() {
       return {
         video: {enabled: true, visible: true},
         audio: {enabled: true, visible: true}
       };
     },
 
     getInitialState: function() {
-      return this.getStoreState();
+      return this.props.conversationStore.getStoreState();
+    },
+
+    componentWillMount: function() {
+      this.props.conversationStore.on("change", function() {
+        this.setState(this.props.conversationStore.getStoreState());
+      }, this);
+    },
+
+    componentWillUnmount: function() {
+      this.props.conversationStore.off("change", null, this);
     },
 
     componentDidMount: function() {
       // The SDK needs to know about the configuration and the elements to use
       // for display. So the best way seems to pass the information here - ideally
       // the sdk wouldn't need to know this, but we can't change that.
       this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
         publisherConfig: this.getDefaultPublisherConfig({
@@ -628,64 +640,79 @@ loop.conversationViews = (function(mozL1
     publishStream: function(type, enabled) {
       this.props.dispatcher.dispatch(
         new sharedActions.SetMute({
           type: type,
           enabled: enabled
         }));
     },
 
+    /**
+     * Should we render a visual cue to the user (e.g. a spinner) that a local
+     * stream is on its way from the camera?
+     *
+     * @returns {boolean}
+     * @private
+     */
+    _isLocalLoading: function () {
+      return !this.state.localSrcVideoObject && !this.props.localPosterUrl;
+    },
+
+    /**
+     * Should we render a visual cue to the user (e.g. a spinner) that a remote
+     * stream is on its way from the other user?
+     *
+     * @returns {boolean}
+     * @private
+     */
+    _isRemoteLoading: function() {
+      return !!(!this.state.remoteSrcVideoObject &&
+                !this.props.remotePosterUrl &&
+                !this.state.mediaConnected);
+    },
+
     shouldRenderRemoteVideo: function() {
       if (this.props.mediaConnected) {
         // If remote video is not enabled, we're muted, so we'll show an avatar
         // instead.
         return this.props.remoteVideoEnabled;
       }
 
       // We're not yet connected, but we don't want to show the avatar, and in
       // the common case, we'll just transition to the video.
       return true;
     },
 
     render: function() {
-      var localStreamClasses = React.addons.classSet({
-        local: true,
-        "local-stream": true,
-        "local-stream-audio": !this.props.video.enabled
-      });
-
       return (
-        React.createElement("div", {className: "video-layout-wrapper"}, 
-          React.createElement("div", {className: "conversation"}, 
-            React.createElement("div", {className: "media nested"}, 
-              React.createElement("div", {className: "video_wrapper remote_wrapper"}, 
-                React.createElement("div", {className: "video_inner remote focus-stream"}, 
-                  React.createElement(sharedViews.MediaView, {displayAvatar: !this.shouldRenderRemoteVideo(), 
-                    isLoading: false, 
-                    mediaType: "remote", 
-                    posterUrl: this.props.remotePosterUrl, 
-                    srcVideoObject: this.state.remoteSrcVideoObject})
-                )
-              ), 
-              React.createElement("div", {className: localStreamClasses}, 
-                React.createElement(sharedViews.MediaView, {displayAvatar: !this.props.video.enabled, 
-                  isLoading: false, 
-                  mediaType: "local", 
-                  posterUrl: this.props.localPosterUrl, 
-                  srcVideoObject: this.state.localSrcVideoObject})
-              )
-            ), 
-            React.createElement(loop.shared.views.ConversationToolbar, {
-              audio: this.props.audio, 
-              dispatcher: this.props.dispatcher, 
-              edit: { visible: false, enabled: false}, 
-              hangup: this.hangup, 
-              publishStream: this.publishStream, 
-              video: this.props.video})
-          )
+        React.createElement("div", {className: "desktop-call-wrapper"}, 
+          React.createElement(sharedViews.MediaLayoutView, {
+            dispatcher: this.props.dispatcher, 
+            displayScreenShare: false, 
+            isLocalLoading: this._isLocalLoading(), 
+            isRemoteLoading: this._isRemoteLoading(), 
+            isScreenShareLoading: false, 
+            localPosterUrl: this.props.localPosterUrl, 
+            localSrcVideoObject: this.state.localSrcVideoObject, 
+            localVideoMuted: !this.props.video.enabled, 
+            matchMedia: this.state.matchMedia || window.matchMedia.bind(window), 
+            remotePosterUrl: this.props.remotePosterUrl, 
+            remoteSrcVideoObject: this.state.remoteSrcVideoObject, 
+            renderRemoteVideo: this.shouldRenderRemoteVideo(), 
+            screenSharePosterUrl: null, 
+            screenShareVideoObject: this.state.screenShareVideoObject, 
+            showContextRoomName: false, 
+            useDesktopPaths: true}), 
+          React.createElement(loop.shared.views.ConversationToolbar, {
+            audio: this.props.audio, 
+            dispatcher: this.props.dispatcher, 
+            edit: { visible: false, enabled: false}, 
+            hangup: this.hangup, 
+            publishStream: this.publishStream, 
+            video: this.props.video})
         )
       );
     }
   });
 
   /**
    * Master View Controller for outgoing calls. This manages
    * the different views that need displaying.
@@ -773,16 +800,17 @@ loop.conversationViews = (function(mozL1
           return (React.createElement(CallFailedView, {
             contact: this.state.contact, 
             dispatcher: this.props.dispatcher, 
             outgoing: this.state.outgoing}));
         }
         case CALL_STATES.ONGOING: {
           return (React.createElement(OngoingConversationView, {
             audio: {enabled: !this.state.audioMuted}, 
+            conversationStore: this.getStore(), 
             dispatcher: this.props.dispatcher, 
             mediaConnected: this.state.mediaConnected, 
             remoteSrcVideoObject: this.state.remoteSrcVideoObject, 
             remoteVideoEnabled: this.state.remoteVideoEnabled, 
             video: {enabled: !this.state.videoMuted}})
           );
         }
         case CALL_STATES.FINISHED: {
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -565,23 +565,25 @@ loop.conversationViews = (function(mozL1
           </div>
         </div>
       );
     }
   });
 
   var OngoingConversationView = React.createClass({
     mixins: [
-      loop.store.StoreMixin("conversationStore"),
       sharedMixins.MediaSetupMixin
     ],
 
     propTypes: {
       // local
       audio: React.PropTypes.object,
+      // We pass conversationStore here rather than use the mixin, to allow
+      // easy configurability for the ui-showcase.
+      conversationStore: React.PropTypes.instanceOf(loop.store.ConversationStore).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // The poster URLs are for UI-showcase testing and development.
       localPosterUrl: React.PropTypes.string,
       // This is used from the props rather than the state to make it easier for
       // the ui-showcase.
       mediaConnected: React.PropTypes.bool,
       remotePosterUrl: React.PropTypes.string,
       remoteVideoEnabled: React.PropTypes.bool,
@@ -592,17 +594,27 @@ loop.conversationViews = (function(mozL1
     getDefaultProps: function() {
       return {
         video: {enabled: true, visible: true},
         audio: {enabled: true, visible: true}
       };
     },
 
     getInitialState: function() {
-      return this.getStoreState();
+      return this.props.conversationStore.getStoreState();
+    },
+
+    componentWillMount: function() {
+      this.props.conversationStore.on("change", function() {
+        this.setState(this.props.conversationStore.getStoreState());
+      }, this);
+    },
+
+    componentWillUnmount: function() {
+      this.props.conversationStore.off("change", null, this);
     },
 
     componentDidMount: function() {
       // The SDK needs to know about the configuration and the elements to use
       // for display. So the best way seems to pass the information here - ideally
       // the sdk wouldn't need to know this, but we can't change that.
       this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
         publisherConfig: this.getDefaultPublisherConfig({
@@ -628,64 +640,79 @@ loop.conversationViews = (function(mozL1
     publishStream: function(type, enabled) {
       this.props.dispatcher.dispatch(
         new sharedActions.SetMute({
           type: type,
           enabled: enabled
         }));
     },
 
+    /**
+     * Should we render a visual cue to the user (e.g. a spinner) that a local
+     * stream is on its way from the camera?
+     *
+     * @returns {boolean}
+     * @private
+     */
+    _isLocalLoading: function () {
+      return !this.state.localSrcVideoObject && !this.props.localPosterUrl;
+    },
+
+    /**
+     * Should we render a visual cue to the user (e.g. a spinner) that a remote
+     * stream is on its way from the other user?
+     *
+     * @returns {boolean}
+     * @private
+     */
+    _isRemoteLoading: function() {
+      return !!(!this.state.remoteSrcVideoObject &&
+                !this.props.remotePosterUrl &&
+                !this.state.mediaConnected);
+    },
+
     shouldRenderRemoteVideo: function() {
       if (this.props.mediaConnected) {
         // If remote video is not enabled, we're muted, so we'll show an avatar
         // instead.
         return this.props.remoteVideoEnabled;
       }
 
       // We're not yet connected, but we don't want to show the avatar, and in
       // the common case, we'll just transition to the video.
       return true;
     },
 
     render: function() {
-      var localStreamClasses = React.addons.classSet({
-        local: true,
-        "local-stream": true,
-        "local-stream-audio": !this.props.video.enabled
-      });
-
       return (
-        <div className="video-layout-wrapper">
-          <div className="conversation">
-            <div className="media nested">
-              <div className="video_wrapper remote_wrapper">
-                <div className="video_inner remote focus-stream">
-                  <sharedViews.MediaView displayAvatar={!this.shouldRenderRemoteVideo()}
-                    isLoading={false}
-                    mediaType="remote"
-                    posterUrl={this.props.remotePosterUrl}
-                    srcVideoObject={this.state.remoteSrcVideoObject} />
-                </div>
-              </div>
-              <div className={localStreamClasses}>
-                <sharedViews.MediaView displayAvatar={!this.props.video.enabled}
-                  isLoading={false}
-                  mediaType="local"
-                  posterUrl={this.props.localPosterUrl}
-                  srcVideoObject={this.state.localSrcVideoObject} />
-              </div>
-            </div>
-            <loop.shared.views.ConversationToolbar
-              audio={this.props.audio}
-              dispatcher={this.props.dispatcher}
-              edit={{ visible: false, enabled: false }}
-              hangup={this.hangup}
-              publishStream={this.publishStream}
-              video={this.props.video} />
-          </div>
+        <div className="desktop-call-wrapper">
+          <sharedViews.MediaLayoutView
+            dispatcher={this.props.dispatcher}
+            displayScreenShare={false}
+            isLocalLoading={this._isLocalLoading()}
+            isRemoteLoading={this._isRemoteLoading()}
+            isScreenShareLoading={false}
+            localPosterUrl={this.props.localPosterUrl}
+            localSrcVideoObject={this.state.localSrcVideoObject}
+            localVideoMuted={!this.props.video.enabled}
+            matchMedia={this.state.matchMedia || window.matchMedia.bind(window)}
+            remotePosterUrl={this.props.remotePosterUrl}
+            remoteSrcVideoObject={this.state.remoteSrcVideoObject}
+            renderRemoteVideo={this.shouldRenderRemoteVideo()}
+            screenSharePosterUrl={null}
+            screenShareVideoObject={this.state.screenShareVideoObject}
+            showContextRoomName={false}
+            useDesktopPaths={true} />
+          <loop.shared.views.ConversationToolbar
+            audio={this.props.audio}
+            dispatcher={this.props.dispatcher}
+            edit={{ visible: false, enabled: false }}
+            hangup={this.hangup}
+            publishStream={this.publishStream}
+            video={this.props.video} />
         </div>
       );
     }
   });
 
   /**
    * Master View Controller for outgoing calls. This manages
    * the different views that need displaying.
@@ -773,16 +800,17 @@ loop.conversationViews = (function(mozL1
           return (<CallFailedView
             contact={this.state.contact}
             dispatcher={this.props.dispatcher}
             outgoing={this.state.outgoing} />);
         }
         case CALL_STATES.ONGOING: {
           return (<OngoingConversationView
             audio={{enabled: !this.state.audioMuted}}
+            conversationStore={this.getStore()}
             dispatcher={this.props.dispatcher}
             mediaConnected={this.state.mediaConnected}
             remoteSrcVideoObject={this.state.remoteSrcVideoObject}
             remoteVideoEnabled={this.state.remoteVideoEnabled}
             video={{enabled: !this.state.videoMuted}} />
           );
         }
         case CALL_STATES.FINISHED: {
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -587,17 +587,17 @@
   }
 }
 
 .conversation .local .avatar {
   position: absolute;
   z-index: 1;
 }
 
-.remote .avatar {
+.remote > .avatar {
   /* make visually distinct from local avatar */
   opacity: 0.25;
 }
 
 .fx-embedded .media.nested {
   min-height: 200px;
 }
 
@@ -670,17 +670,18 @@
   }
 
 /* Force full height on all parents up to the video elements
  * this way we can ensure the aspect ratio and use height 100%
  * on the video element
  * */
 html, .fx-embedded, #main,
 .video-layout-wrapper,
-.conversation {
+.conversation,
+.desktop-call-wrapper {
   height: 100%;
 }
 
 /* We use 641px rather than 640, as min-width and max-width are inclusive */
 @media screen and (min-width: 641px) {
   .standalone .conversation .conversation-toolbar {
     position: absolute;
     bottom: 0;
@@ -1082,22 +1083,22 @@ html[dir="rtl"] .room-context-btn-close 
 
 .media-layout {
   height: 100%;
 }
 
 .standalone-room-wrapper > .media-layout {
   /* 50px is the header, 64px for toolbar, 3em is the footer. */
   height: calc(100% - 50px - 64px - 3em);
+  margin: 0 10px;
 }
 
 .media-layout > .media-wrapper {
   display: flex;
   flex-flow: column wrap;
-  margin: 0 10px;
   height: 100%;
 }
 
 .media-wrapper > .focus-stream {
   /* We want this to be the width, minus 200px which is for the right-side text
      chat and video displays. */
   width: calc(100% - 200px);
   /* 100% height to fill up media-layout, thus forcing other elements into the
@@ -1134,16 +1135,23 @@ html[dir="rtl"] .room-context-btn-close 
 }
 
 .media-wrapper.showing-local-streams.receiving-screen-share > .text-chat-view {
   /* When we're displaying the local streams, then we need to make the text
      chat view a bit shorter to give room. */
   height: calc(100% - 300px);
 }
 
+.desktop-call-wrapper > .media-layout > .media-wrapper > .text-chat-view {
+  /* Account for height of .conversation-toolbar on desktop */
+  /* When we change the toolbar in bug 1184559 we can remove this. */
+  margin-top: 26px;
+  height: calc(100% - 150px - 26px);
+}
+
 /* Temporarily slaved from .media-wrapper until we use it in more places
    to avoid affecting the conversation window on desktop. */
 .media-wrapper > .text-chat-view > .text-chat-entries {
   /* 40px is the height of .text-chat-box. */
   height: calc(100% - 40px);
 }
 
 .media-wrapper > .text-chat-disabled > .text-chat-entries {
@@ -1210,30 +1218,36 @@ html[dir="rtl"] .room-context-btn-close 
     width: 100%;
   }
 
   .media-wrapper > .text-chat-disabled > .text-chat-entries {
     /* When text chat is disabled, the entries box should be 100% height. */
     height: 100%;
   }
 
-  .media-wrapper > .local {
+  .media-wrapper > .focus-stream > .local {
     /* Position over the remote video */
     position: absolute;
     /* Make sure its on top */
     z-index: 1001;
     margin: 3px;
     right: 0;
     /* 29px is (30% of 50px high header) + (height toolbar (38px) +
        height footer (25px) - height header (50px)) */
-    bottom: calc(30% + 29px);
+    bottom: 0;
     width: 120px;
     height: 120px;
   }
 
+  .standalone-room-wrapper > .media-layout > .media-wrapper > .local {
+    /* Add 10px for the margin on standalone */
+    right: 10px;
+  }
+
+
   html[dir="rtl"] .media-wrapper > .local {
     right: auto;
     left: 0;
   }
 
   .media-wrapper > .text-chat-view {
     order: 3;
     flex: 1 1 auto;
@@ -1242,16 +1256,24 @@ html[dir="rtl"] .room-context-btn-close 
 
   .media-wrapper > .text-chat-view,
   .media-wrapper.showing-local-streams > .text-chat-view,
   .media-wrapper.showing-local-streams.receiving-screen-share > .text-chat-view {
     /* The remaining 30% that the .focus-stream doesn't use. */
     height: 30%;
   }
 
+  .desktop-call-wrapper > .media-layout > .media-wrapper > .text-chat-view {
+    /* When we change the toolbar in bug 1184559 we can remove this. */
+    /* Reset back to 0 for .conversation-toolbar override on desktop */
+    margin-top: 0;
+    /* This is temp, to echo the .media-wrapper > .text-chat-view above */
+    height: 30%;
+  }
+
   .media-wrapper.receiving-screen-share > .screen {
     order: 1;
   }
 
   .media-wrapper.receiving-screen-share > .remote {
     /* Screen shares have remote & local video side-by-side on narrow screens */
     order: 2;
     flex: 1 1 auto;
@@ -1283,16 +1305,56 @@ html[dir="rtl"] .room-context-btn-close 
     margin: 0;
   }
 
   .media-wrapper.receiving-screen-share > .text-chat-view {
     order: 4;
   }
 }
 
+/* e.g. very narrow widths similar to conversation window */
+@media screen and (max-width:300px) {
+  .media-layout > .media-wrapper {
+    flex-flow: column nowrap;
+  }
+
+  .media-wrapper:not(.showing-remote-streams) > .remote {
+    display: none;
+  }
+
+  .media-wrapper > .focus-stream > .local {
+    position: absolute;
+    right: 0;
+    /* 30% is the height of the text chat. As we have a margin,
+       we don't need to worry about any offset for a border */
+    bottom: 0;
+    margin: 3px;
+    object-fit: contain;
+    /* These make the avatar look reasonable and the local
+       video not too big */
+    width: 25%;
+    height: 25%;
+  }
+
+  .media-wrapper:not(.showing-remote-streams) > .local {
+    position: relative;
+    margin: 0;
+    right: auto;
+    left: auto;
+    bottom: auto;
+    width: 100%;
+    height: 100%;
+  }
+
+  .media-wrapper > .focus-stream {
+    flex: 1 1 auto;
+    height: auto;
+  }
+}
+
 .standalone > #main > .room-conversation-wrapper > .media-layout > .conversation-toolbar {
   border: none;
 }
 
 /* Standalone rooms */
 
 .standalone .room-conversation-wrapper {
   position: relative;
@@ -1410,47 +1472,49 @@ html[dir="rtl"] .standalone .room-conver
   display: block;
 }
 
 .standalone .room-conversation-wrapper .ended-conversation {
   position: relative;
   height: auto;
 }
 
-/* Text chat in rooms styles */
-
-.fx-embedded .room-conversation-wrapper {
-  display: flex;
-  flex-flow: column nowrap;
-}
-
-.fx-embedded .video-layout-wrapper {
-  flex: 1 1 auto;
-}
+/* Text chat in styles */
 
 .text-chat-view {
   background: white;
 }
 
-.fx-embedded .text-chat-view {
+/* Old styles to cope with rooms until we transition those to media-layout. */
+.fx-embedded .room-conversation-wrapper {
+  display: flex;
+  flex-flow: column nowrap;
+}
+
+.fx-embedded .room-conversation-wrapper > .video-layout-wrapper  {
+  flex: 1 1 auto;
+}
+
+.fx-embedded .room-conversation-wrapper > .text-chat-view {
   flex: 1 0 auto;
   display: flex;
   flex-flow: column nowrap;
 }
 
-.fx-embedded .text-chat-entries {
+.fx-embedded .room-conversation-wrapper > .text-chat-view .text-chat-entries {
   flex: 1 1 auto;
   max-height: 120px;
   min-height: 60px;
 }
 
-.fx-embedded .text-chat-view > .text-chat-entries-empty {
+.fx-embedded .room-conversation-wrapper > .text-chat-view > .text-chat-entries-empty {
   display: none;
 }
 
+
 .text-chat-box {
   flex: 0 0 auto;
   max-height: 40px;
   min-height: 40px;
   width: 100%;
 }
 
 .text-chat-entries {
@@ -1735,16 +1799,61 @@ html[dir="rtl"] .text-chat-entry.receive
   }
 
   .standalone .media.nested {
     /* This forces the remote video stream to fit within wrapper's height */
     min-height: 0px;
   }
 }
 
+/* e.g. very narrow widths similar to conversation window */
+@media screen and (max-width:300px) {
+  /**
+   * XXX This section scoped to .media-layout until we have the desktop
+   * rooms working from the media-layout as well.
+   */
+  .media-layout .text-chat-view {
+    flex: 0 0 auto;
+    display: flex;
+    flex-flow: column nowrap;
+    /* 120px max-height of .text-chat-entries plus 40px of .text-chat-box */
+    max-height: 160px;
+    /* 60px min-height of .text-chat-entries plus 40px of .text-chat-box */
+    min-height: 100px;
+    /* The !important is to override the values defined above which have more
+       specificity when we fix bug 1184559, we should be able to remove it,
+       but this should be tests first. */
+    height: auto !important;
+  }
+
+  .media-layout .text-chat-entries {
+    /* The !important is to override the values defined above which have more
+       specificity when we fix bug 1184559, we should be able to remove it,
+       but this should be tests first. */
+    flex: 1 1 auto !important;
+    max-height: 120px;
+    min-height: 60px;
+  }
+
+  .media-layout .text-chat-entries-empty.text-chat-disabled {
+    display: none;
+  }
+
+  /* When the text chat entries are not present, then hide the entries view
+     and just show the chat box. */
+  .media-layout .text-chat-entries-empty {
+    max-height: 40px;
+    min-height: 40px;
+  }
+
+  .media-layout .text-chat-entries-empty > .text-chat-entries {
+    display: none;
+  }
+}
+
 .self-view-hidden-message {
   /* Not displayed by default; display is turned on elsewhere when the
    * self-view is actually hidden.
    */
   display: none;
 }
 
 /* Avoid the privacy problem where a user can size the window so small that
--- a/browser/components/loop/content/shared/js/textChatView.js
+++ b/browser/components/loop/content/shared/js/textChatView.js
@@ -146,16 +146,18 @@ loop.shared.views.chat = (function(mozL1
     },
 
     render: function() {
       /* Keep track of the last printed timestamp. */
       var lastTimestamp = 0;
 
       var entriesClasses = React.addons.classSet({
         "text-chat-entries": true,
+        // XXX Only required until we get the desktop rooms on media-layout
+        // as well.
         "text-chat-entries-empty": !this.props.messageList.length
       });
 
       return (
         React.createElement("div", {className: entriesClasses}, 
           React.createElement("div", {className: "text-chat-scroller"}, 
             
               this.props.messageList.map(function(entry, i) {
@@ -377,17 +379,18 @@ loop.shared.views.chat = (function(mozL1
           return item.type !== CHAT_MESSAGE_TYPES.SPECIAL ||
             item.contentType !== CHAT_CONTENT_TYPES.ROOM_NAME;
         });
         hasNonSpecialMessages = !!messageList.length;
       }
 
       var textChatViewClasses = React.addons.classSet({
         "text-chat-view": true,
-        "text-chat-disabled": !this.state.textChatEnabled
+        "text-chat-disabled": !this.state.textChatEnabled,
+        "text-chat-entries-empty": !this.state.messageList.length
       });
 
       return (
         React.createElement("div", {className: textChatViewClasses}, 
           React.createElement(TextChatEntriesView, {
             dispatcher: this.props.dispatcher, 
             messageList: messageList, 
             useDesktopPaths: this.props.useDesktopPaths}), 
--- a/browser/components/loop/content/shared/js/textChatView.jsx
+++ b/browser/components/loop/content/shared/js/textChatView.jsx
@@ -146,16 +146,18 @@ loop.shared.views.chat = (function(mozL1
     },
 
     render: function() {
       /* Keep track of the last printed timestamp. */
       var lastTimestamp = 0;
 
       var entriesClasses = React.addons.classSet({
         "text-chat-entries": true,
+        // XXX Only required until we get the desktop rooms on media-layout
+        // as well.
         "text-chat-entries-empty": !this.props.messageList.length
       });
 
       return (
         <div className={entriesClasses}>
           <div className="text-chat-scroller">
             {
               this.props.messageList.map(function(entry, i) {
@@ -377,17 +379,18 @@ loop.shared.views.chat = (function(mozL1
           return item.type !== CHAT_MESSAGE_TYPES.SPECIAL ||
             item.contentType !== CHAT_CONTENT_TYPES.ROOM_NAME;
         });
         hasNonSpecialMessages = !!messageList.length;
       }
 
       var textChatViewClasses = React.addons.classSet({
         "text-chat-view": true,
-        "text-chat-disabled": !this.state.textChatEnabled
+        "text-chat-disabled": !this.state.textChatEnabled,
+        "text-chat-entries-empty": !this.state.messageList.length
       });
 
       return (
         <div className={textChatViewClasses}>
           <TextChatEntriesView
             dispatcher={this.props.dispatcher}
             messageList={messageList}
             useDesktopPaths={this.props.useDesktopPaths} />
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -950,74 +950,130 @@ loop.shared.views = (function(_, mozL10n
       displayScreenShare: React.PropTypes.bool.isRequired,
       isLocalLoading: React.PropTypes.bool.isRequired,
       isRemoteLoading: React.PropTypes.bool.isRequired,
       isScreenShareLoading: React.PropTypes.bool.isRequired,
       // The poster URLs are for UI-showcase testing and development.
       localPosterUrl: React.PropTypes.string,
       localSrcVideoObject: React.PropTypes.object,
       localVideoMuted: React.PropTypes.bool.isRequired,
+      // Passing in matchMedia, allows it to be overriden for ui-showcase's
+      // benefit. We expect either the override or window.matchMedia.
+      matchMedia: React.PropTypes.func.isRequired,
       remotePosterUrl: React.PropTypes.string,
       remoteSrcVideoObject: React.PropTypes.object,
       renderRemoteVideo: React.PropTypes.bool.isRequired,
       screenSharePosterUrl: React.PropTypes.string,
       screenShareVideoObject: React.PropTypes.object,
       showContextRoomName: React.PropTypes.bool.isRequired,
       useDesktopPaths: React.PropTypes.bool.isRequired
     },
 
+    isLocalMediaAbsolutelyPositioned: function(matchMedia) {
+      if (!matchMedia) {
+        matchMedia = this.props.matchMedia;
+      }
+      return matchMedia &&
+        // The screen width is less than 640px and we are not screen sharing.
+        ((matchMedia("screen and (max-width:640px)").matches &&
+         !this.props.displayScreenShare) ||
+         // or the screen width is less than 300px.
+         (matchMedia("screen and (max-width:300px)").matches));
+    },
+
+    getInitialState: function() {
+      return {
+        localMediaAboslutelyPositioned: this.isLocalMediaAbsolutelyPositioned()
+      };
+    },
+
+    componentWillReceiveProps: function(nextProps) {
+      // This is all for the ui-showcase's benefit.
+      if (this.props.matchMedia != nextProps.matchMedia) {
+        this.updateLocalMediaState(null, nextProps.matchMedia);
+      }
+    },
+
+    componentDidMount: function() {
+      window.addEventListener("resize", this.updateLocalMediaState);
+    },
+
+    componentWillUnmount: function() {
+      window.removeEventListener("resize", this.updateLocalMediaState);
+    },
+
+    updateLocalMediaState: function(event, matchMedia) {
+      var newState = this.isLocalMediaAbsolutelyPositioned(matchMedia);
+      if (this.state.localMediaAboslutelyPositioned != newState) {
+        this.setState({
+          localMediaAboslutelyPositioned: newState
+        });
+      }
+    },
+
+    renderLocalVideo: function() {
+      return (
+        React.createElement("div", {className: "local"}, 
+          React.createElement(MediaView, {displayAvatar: this.props.localVideoMuted, 
+            isLoading: this.props.isLocalLoading, 
+            mediaType: "local", 
+            posterUrl: this.props.localPosterUrl, 
+            srcVideoObject: this.props.localSrcVideoObject})
+        )
+      );
+    },
+
     render: function() {
       var remoteStreamClasses = React.addons.classSet({
         "remote": true,
         "focus-stream": !this.props.displayScreenShare
       });
 
       var screenShareStreamClasses = React.addons.classSet({
         "screen": true,
         "focus-stream": this.props.displayScreenShare
       });
 
       var mediaWrapperClasses = React.addons.classSet({
         "media-wrapper": true,
         "receiving-screen-share": this.props.displayScreenShare,
         "showing-local-streams": this.props.localSrcVideoObject ||
-          this.props.localPosterUrl
+          this.props.localPosterUrl,
+        "showing-remote-streams": this.props.remoteSrcVideoObject ||
+          this.props.remotePosterUrl || this.props.isRemoteLoading
       });
 
       return (
         React.createElement("div", {className: "media-layout"}, 
           React.createElement("div", {className: mediaWrapperClasses}, 
             React.createElement("span", {className: "self-view-hidden-message"}, 
               mozL10n.get("self_view_hidden_message")
             ), 
             React.createElement("div", {className: remoteStreamClasses}, 
               React.createElement(MediaView, {displayAvatar: !this.props.renderRemoteVideo, 
                 isLoading: this.props.isRemoteLoading, 
                 mediaType: "remote", 
                 posterUrl: this.props.remotePosterUrl, 
-                srcVideoObject: this.props.remoteSrcVideoObject})
+                srcVideoObject: this.props.remoteSrcVideoObject}), 
+               this.state.localMediaAboslutelyPositioned ?
+                this.renderLocalVideo() : null
             ), 
             React.createElement("div", {className: screenShareStreamClasses}, 
               React.createElement(MediaView, {displayAvatar: false, 
                 isLoading: this.props.isScreenShareLoading, 
                 mediaType: "screen-share", 
                 posterUrl: this.props.screenSharePosterUrl, 
                 srcVideoObject: this.props.screenShareVideoObject})
             ), 
             React.createElement(loop.shared.views.chat.TextChatView, {
               dispatcher: this.props.dispatcher, 
               showRoomName: this.props.showContextRoomName, 
               useDesktopPaths: false}), 
-            React.createElement("div", {className: "local"}, 
-              React.createElement(MediaView, {displayAvatar: this.props.localVideoMuted, 
-                isLoading: this.props.isLocalLoading, 
-                mediaType: "local", 
-                posterUrl: this.props.localPosterUrl, 
-                srcVideoObject: this.props.localSrcVideoObject})
-            )
+             this.state.localMediaAboslutelyPositioned ?
+              null : this.renderLocalVideo()
           )
         )
       );
     }
   });
 
   return {
     AvatarView: AvatarView,
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -950,74 +950,130 @@ loop.shared.views = (function(_, mozL10n
       displayScreenShare: React.PropTypes.bool.isRequired,
       isLocalLoading: React.PropTypes.bool.isRequired,
       isRemoteLoading: React.PropTypes.bool.isRequired,
       isScreenShareLoading: React.PropTypes.bool.isRequired,
       // The poster URLs are for UI-showcase testing and development.
       localPosterUrl: React.PropTypes.string,
       localSrcVideoObject: React.PropTypes.object,
       localVideoMuted: React.PropTypes.bool.isRequired,
+      // Passing in matchMedia, allows it to be overriden for ui-showcase's
+      // benefit. We expect either the override or window.matchMedia.
+      matchMedia: React.PropTypes.func.isRequired,
       remotePosterUrl: React.PropTypes.string,
       remoteSrcVideoObject: React.PropTypes.object,
       renderRemoteVideo: React.PropTypes.bool.isRequired,
       screenSharePosterUrl: React.PropTypes.string,
       screenShareVideoObject: React.PropTypes.object,
       showContextRoomName: React.PropTypes.bool.isRequired,
       useDesktopPaths: React.PropTypes.bool.isRequired
     },
 
+    isLocalMediaAbsolutelyPositioned: function(matchMedia) {
+      if (!matchMedia) {
+        matchMedia = this.props.matchMedia;
+      }
+      return matchMedia &&
+        // The screen width is less than 640px and we are not screen sharing.
+        ((matchMedia("screen and (max-width:640px)").matches &&
+         !this.props.displayScreenShare) ||
+         // or the screen width is less than 300px.
+         (matchMedia("screen and (max-width:300px)").matches));
+    },
+
+    getInitialState: function() {
+      return {
+        localMediaAboslutelyPositioned: this.isLocalMediaAbsolutelyPositioned()
+      };
+    },
+
+    componentWillReceiveProps: function(nextProps) {
+      // This is all for the ui-showcase's benefit.
+      if (this.props.matchMedia != nextProps.matchMedia) {
+        this.updateLocalMediaState(null, nextProps.matchMedia);
+      }
+    },
+
+    componentDidMount: function() {
+      window.addEventListener("resize", this.updateLocalMediaState);
+    },
+
+    componentWillUnmount: function() {
+      window.removeEventListener("resize", this.updateLocalMediaState);
+    },
+
+    updateLocalMediaState: function(event, matchMedia) {
+      var newState = this.isLocalMediaAbsolutelyPositioned(matchMedia);
+      if (this.state.localMediaAboslutelyPositioned != newState) {
+        this.setState({
+          localMediaAboslutelyPositioned: newState
+        });
+      }
+    },
+
+    renderLocalVideo: function() {
+      return (
+        <div className="local">
+          <MediaView displayAvatar={this.props.localVideoMuted}
+            isLoading={this.props.isLocalLoading}
+            mediaType="local"
+            posterUrl={this.props.localPosterUrl}
+            srcVideoObject={this.props.localSrcVideoObject} />
+        </div>
+      );
+    },
+
     render: function() {
       var remoteStreamClasses = React.addons.classSet({
         "remote": true,
         "focus-stream": !this.props.displayScreenShare
       });
 
       var screenShareStreamClasses = React.addons.classSet({
         "screen": true,
         "focus-stream": this.props.displayScreenShare
       });
 
       var mediaWrapperClasses = React.addons.classSet({
         "media-wrapper": true,
         "receiving-screen-share": this.props.displayScreenShare,
         "showing-local-streams": this.props.localSrcVideoObject ||
-          this.props.localPosterUrl
+          this.props.localPosterUrl,
+        "showing-remote-streams": this.props.remoteSrcVideoObject ||
+          this.props.remotePosterUrl || this.props.isRemoteLoading
       });
 
       return (
         <div className="media-layout">
           <div className={mediaWrapperClasses}>
             <span className="self-view-hidden-message">
               {mozL10n.get("self_view_hidden_message")}
             </span>
             <div className={remoteStreamClasses}>
               <MediaView displayAvatar={!this.props.renderRemoteVideo}
                 isLoading={this.props.isRemoteLoading}
                 mediaType="remote"
                 posterUrl={this.props.remotePosterUrl}
                 srcVideoObject={this.props.remoteSrcVideoObject} />
+              { this.state.localMediaAboslutelyPositioned ?
+                this.renderLocalVideo() : null }
             </div>
             <div className={screenShareStreamClasses}>
               <MediaView displayAvatar={false}
                 isLoading={this.props.isScreenShareLoading}
                 mediaType="screen-share"
                 posterUrl={this.props.screenSharePosterUrl}
                 srcVideoObject={this.props.screenShareVideoObject} />
             </div>
             <loop.shared.views.chat.TextChatView
               dispatcher={this.props.dispatcher}
               showRoomName={this.props.showContextRoomName}
               useDesktopPaths={false} />
-            <div className="local">
-              <MediaView displayAvatar={this.props.localVideoMuted}
-                isLoading={this.props.isLocalLoading}
-                mediaType="local"
-                posterUrl={this.props.localPosterUrl}
-                srcVideoObject={this.props.localSrcVideoObject} />
-            </div>
+            { this.state.localMediaAboslutelyPositioned ?
+              null : this.renderLocalVideo() }
           </div>
         </div>
       );
     }
   });
 
   return {
     AvatarView: AvatarView,
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -251,21 +251,22 @@ loop.standaloneRoomViews = (function(moz
       );
     }
   });
 
   var StandaloneRoomView = React.createClass({displayName: "StandaloneRoomView",
     mixins: [
       Backbone.Events,
       sharedMixins.MediaSetupMixin,
-      sharedMixins.RoomsAudioMixin,
-      loop.store.StoreMixin("activeRoomStore")
+      sharedMixins.RoomsAudioMixin
     ],
 
     propTypes: {
+      // We pass conversationStore here rather than use the mixin, to allow
+      // easy configurability for the ui-showcase.
       activeRoomStore: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
         React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
       ]).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       isFirefox: React.PropTypes.bool.isRequired,
       // The poster URLs are for UI-showcase testing and development
       localPosterUrl: React.PropTypes.string,
@@ -277,16 +278,26 @@ loop.standaloneRoomViews = (function(moz
     getInitialState: function() {
       var storeState = this.props.activeRoomStore.getStoreState();
       return _.extend({}, storeState, {
         // Used by the UI showcase.
         roomState: this.props.roomState || storeState.roomState
       });
     },
 
+    componentWillMount: function() {
+      this.props.activeRoomStore.on("change", function() {
+        this.setState(this.props.activeRoomStore.getStoreState());
+      }, this);
+    },
+
+    componentWillUnmount: function() {
+      this.props.activeRoomStore.off("change", null, this);
+    },
+
     componentDidMount: function() {
       // Adding a class to the document body element from here to ease styling it.
       document.body.classList.add("is-standalone-room");
     },
 
     /**
      * Watches for when we transition to MEDIA_WAIT room state, so we can request
      * user media access.
@@ -451,16 +462,17 @@ loop.standaloneRoomViews = (function(moz
             dispatcher: this.props.dispatcher, 
             displayScreenShare: displayScreenShare, 
             isLocalLoading: this._isLocalLoading(), 
             isRemoteLoading: this._isRemoteLoading(), 
             isScreenShareLoading: this._isScreenShareLoading(), 
             localPosterUrl: this.props.localPosterUrl, 
             localSrcVideoObject: this.state.localSrcVideoObject, 
             localVideoMuted: this.state.videoMuted, 
+            matchMedia: this.state.matchMedia || window.matchMedia.bind(window), 
             remotePosterUrl: this.props.remotePosterUrl, 
             remoteSrcVideoObject: this.state.remoteSrcVideoObject, 
             renderRemoteVideo: this.shouldRenderRemoteVideo(), 
             screenSharePosterUrl: this.props.screenSharePosterUrl, 
             screenShareVideoObject: this.state.screenShareVideoObject, 
             showContextRoomName: true, 
             useDesktopPaths: false}), 
           React.createElement(sharedViews.ConversationToolbar, {
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -251,21 +251,22 @@ loop.standaloneRoomViews = (function(moz
       );
     }
   });
 
   var StandaloneRoomView = React.createClass({
     mixins: [
       Backbone.Events,
       sharedMixins.MediaSetupMixin,
-      sharedMixins.RoomsAudioMixin,
-      loop.store.StoreMixin("activeRoomStore")
+      sharedMixins.RoomsAudioMixin
     ],
 
     propTypes: {
+      // We pass conversationStore here rather than use the mixin, to allow
+      // easy configurability for the ui-showcase.
       activeRoomStore: React.PropTypes.oneOfType([
         React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
         React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
       ]).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       isFirefox: React.PropTypes.bool.isRequired,
       // The poster URLs are for UI-showcase testing and development
       localPosterUrl: React.PropTypes.string,
@@ -277,16 +278,26 @@ loop.standaloneRoomViews = (function(moz
     getInitialState: function() {
       var storeState = this.props.activeRoomStore.getStoreState();
       return _.extend({}, storeState, {
         // Used by the UI showcase.
         roomState: this.props.roomState || storeState.roomState
       });
     },
 
+    componentWillMount: function() {
+      this.props.activeRoomStore.on("change", function() {
+        this.setState(this.props.activeRoomStore.getStoreState());
+      }, this);
+    },
+
+    componentWillUnmount: function() {
+      this.props.activeRoomStore.off("change", null, this);
+    },
+
     componentDidMount: function() {
       // Adding a class to the document body element from here to ease styling it.
       document.body.classList.add("is-standalone-room");
     },
 
     /**
      * Watches for when we transition to MEDIA_WAIT room state, so we can request
      * user media access.
@@ -451,16 +462,17 @@ loop.standaloneRoomViews = (function(moz
             dispatcher={this.props.dispatcher}
             displayScreenShare={displayScreenShare}
             isLocalLoading={this._isLocalLoading()}
             isRemoteLoading={this._isRemoteLoading()}
             isScreenShareLoading={this._isScreenShareLoading()}
             localPosterUrl={this.props.localPosterUrl}
             localSrcVideoObject={this.state.localSrcVideoObject}
             localVideoMuted={this.state.videoMuted}
+            matchMedia={this.state.matchMedia || window.matchMedia.bind(window)}
             remotePosterUrl={this.props.remotePosterUrl}
             remoteSrcVideoObject={this.state.remoteSrcVideoObject}
             renderRemoteVideo={this.shouldRenderRemoteVideo()}
             screenSharePosterUrl={this.props.screenSharePosterUrl}
             screenShareVideoObject={this.state.screenShareVideoObject}
             showContextRoomName={true}
             useDesktopPaths={false} />
           <sharedViews.ConversationToolbar
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -469,63 +469,46 @@ describe("loop.conversationViews", funct
         sinon.assert.calledWith(document.mozL10n.get,
           "generic_contact_unavailable_title");
     });
   });
 
   describe("OngoingConversationView", function() {
     function mountTestComponent(extraProps) {
       var props = _.extend({
-        dispatcher: dispatcher
+        conversationStore: conversationStore,
+        dispatcher: dispatcher,
+        matchMedia: window.matchMedia
       }, extraProps);
       return TestUtils.renderIntoDocument(
         React.createElement(loop.conversationViews.OngoingConversationView, props));
     }
 
     it("should dispatch a setupStreamElements action when the view is created",
       function() {
         view = mountTestComponent();
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "setupStreamElements"));
       });
 
-    it("should display an avatar for remote video when the stream is not enabled", function() {
-      view = mountTestComponent({
-        mediaConnected: true,
-        remoteVideoEnabled: false
-      });
-
-      TestUtils.findRenderedComponentWithType(view, sharedViews.AvatarView);
-    });
-
     it("should display the remote video when the stream is enabled", function() {
       conversationStore.setStoreState({
         remoteSrcVideoObject: { fake: 1 }
       });
 
       view = mountTestComponent({
         mediaConnected: true,
         remoteVideoEnabled: true
       });
 
       expect(view.getDOMNode().querySelector(".remote video")).not.eql(null);
     });
 
-    it("should display an avatar for local video when the stream is not enabled", function() {
-      view = mountTestComponent({
-        video: {
-          enabled: false
-        }
-      });
-
-      TestUtils.findRenderedComponentWithType(view, sharedViews.AvatarView);
-    });
-
     it("should display the local video when the stream is enabled", function() {
       conversationStore.setStoreState({
         localSrcVideoObject: { fake: 1 }
       });
 
       view = mountTestComponent({
         video: {
           enabled: true
--- a/browser/components/loop/test/shared/textChatView_test.js
+++ b/browser/components/loop/test/shared/textChatView_test.js
@@ -51,37 +51,16 @@ describe("loop.shared.views.TextChatView
         React.createElement(loop.shared.views.chat.TextChatEntriesView,
           _.extend(basicProps, extraProps)));
     }
 
     beforeEach(function() {
       store.setStoreState({ textChatEnabled: true });
     });
 
-    it("should add an empty class when the list is empty", function() {
-      view = mountTestComponent({
-        messageList: []
-      });
-
-      expect(view.getDOMNode().classList.contains("text-chat-entries-empty")).eql(true);
-    });
-
-    it("should not add an empty class when the list is has items", function() {
-      view = mountTestComponent({
-        messageList: [{
-          type: CHAT_MESSAGE_TYPES.RECEIVED,
-          contentType: CHAT_CONTENT_TYPES.TEXT,
-          message: "Hello!",
-          receivedTimestamp: "2015-06-25T17:53:55.357Z"
-        }]
-      });
-
-      expect(view.getDOMNode().classList.contains("text-chat-entries-empty")).eql(false);
-    });
-
     it("should render message entries when message were sent/ received", function() {
       view = mountTestComponent({
         messageList: [{
           type: CHAT_MESSAGE_TYPES.RECEIVED,
           contentType: CHAT_CONTENT_TYPES.TEXT,
           message: "Hello!",
           receivedTimestamp: "2015-06-25T17:53:55.357Z"
         }, {
@@ -292,16 +271,51 @@ describe("loop.shared.views.TextChatView
       fakeServer = sinon.fakeServer.create();
       store.setStoreState({ textChatEnabled: true });
     });
 
     afterEach(function() {
       fakeServer.restore();
     });
 
+    it("should add a disabled class when text chat is disabled", function() {
+      view = mountTestComponent();
+
+      store.setStoreState({ textChatEnabled: false });
+
+      expect(view.getDOMNode().classList.contains("text-chat-disabled")).eql(true);
+    });
+
+    it("should not a disabled class when text chat is enabled", function() {
+      view = mountTestComponent();
+
+      store.setStoreState({ textChatEnabled: true });
+
+      expect(view.getDOMNode().classList.contains("text-chat-disabled")).eql(false);
+    });
+
+    it("should add an empty class when the entries list is empty", function() {
+      view = mountTestComponent();
+
+      expect(view.getDOMNode().classList.contains("text-chat-entries-empty")).eql(true);
+    });
+
+    it("should not add an empty class when the entries list is has items", function() {
+      view = mountTestComponent();
+
+      store.sendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!",
+        sentTimestamp: "1970-01-01T00:02:00.000Z",
+        receivedTimestamp: "1970-01-01T00:02:00.000Z"
+      });
+
+      expect(view.getDOMNode().classList.contains("text-chat-entries-empty")).eql(false);
+    });
+
     it("should show timestamps from msgs sent more than 1 min apart", function() {
       view = mountTestComponent();
 
       store.sendTextChatMessage({
         contentType: CHAT_CONTENT_TYPES.TEXT,
         message: "Hello!",
         sentTimestamp: "1970-01-01T00:02:00.000Z",
         receivedTimestamp: "1970-01-01T00:02:00.000Z"
@@ -321,22 +335,16 @@ describe("loop.shared.views.TextChatView
       });
 
       var node = view.getDOMNode();
 
       expect(node.querySelectorAll(".text-chat-entry-timestamp").length)
           .to.eql(2);
     });
 
-    it("should display the view if no messages and text chat is enabled", function() {
-      view = mountTestComponent();
-
-      expect(view.getDOMNode()).not.eql(null);
-    });
-
     it("should render message entries when message were sent/ received", function() {
       view = mountTestComponent();
 
       store.receivedTextChatMessage({
         contentType: CHAT_CONTENT_TYPES.TEXT,
         message: "Hello!",
         sentTimestamp: "1970-01-01T00:03:00.000Z",
         receivedTimestamp: "1970-01-01T00:03:00.000Z"
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -1052,16 +1052,17 @@ describe("loop.shared.views", function()
     function mountTestComponent(extraProps) {
       var defaultProps = {
         dispatcher: dispatcher,
         displayScreenShare: false,
         isLocalLoading: false,
         isRemoteLoading: false,
         isScreenShareLoading: false,
         localVideoMuted: false,
+        matchMedia: window.matchMedia,
         renderRemoteVideo: false,
         showContextRoomName: false,
         useDesktopPaths: false
       };
 
       return TestUtils.renderIntoDocument(
         React.createElement(sharedViews.MediaLayoutView,
           _.extend(defaultProps, extraProps)));
@@ -1139,10 +1140,40 @@ describe("loop.shared.views", function()
       view = mountTestComponent({
         localSrcVideoObject: {},
         localPosterUrl: "fake/url"
       });
 
       expect(view.getDOMNode().querySelector(".media-wrapper")
         .classList.contains("showing-local-streams")).eql(true);
     });
+
+    it("should not mark the wrapper as showing remote streams when not displaying a stream", function() {
+      view = mountTestComponent({
+        remoteSrcVideoObject: null,
+        remotePosterUrl: null
+      });
+
+      expect(view.getDOMNode().querySelector(".media-wrapper")
+        .classList.contains("showing-remote-streams")).eql(false);
+    });
+
+    it("should mark the wrapper as showing remote streams when displaying a stream", function() {
+      view = mountTestComponent({
+        remoteSrcVideoObject: {},
+        remotePosterUrl: null
+      });
+
+      expect(view.getDOMNode().querySelector(".media-wrapper")
+        .classList.contains("showing-remote-streams")).eql(true);
+    });
+
+    it("should mark the wrapper as showing remote streams when displaying a poster url", function() {
+      view = mountTestComponent({
+        remoteSrcVideoObject: {},
+        remotePosterUrl: "fake/url"
+      });
+
+      expect(view.getDOMNode().querySelector(".media-wrapper")
+        .classList.contains("showing-remote-streams")).eql(true);
+    });
   });
 });
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -70,23 +70,35 @@
     }
     window.removeEventListener(eventName, func);
   };
 
   loop.shared.mixins.setRootObject(rootObject);
 
   var dispatcher = new loop.Dispatcher();
 
-  var mockSDK = _.extend({
+  var MockSDK = function() {
+    dispatcher.register(this, [
+      "setupStreamElements"
+    ]);
+  };
+
+  MockSDK.prototype = {
+    setupStreamElements: function() {
+      // Dummy function to stop warnings.
+    },
+
     sendTextChatMessage: function(message) {
       dispatcher.dispatch(new loop.shared.actions.ReceivedTextChatMessage({
         message: message.message
       }));
     }
-  }, Backbone.Events);
+  };
+
+  var mockSDK = new MockSDK();
 
   /**
    * Every view that uses an activeRoomStore needs its own; if they shared
    * an active store, they'd interfere with each other.
    *
    * @param options
    * @returns {loop.store.ActiveRoomStore}
    */
@@ -111,17 +123,16 @@
       remoteVideoEnabled: options.remoteVideoEnabled,
       roomName: "A Very Long Conversation Name",
       roomState: options.roomState,
       used: !!options.roomUsed,
       videoMuted: !!options.videoMuted
     });
 
     store.forcedUpdate = function forcedUpdate(contentWindow) {
-
       // Since this is called by setTimeout, we don't want to lose any
       // exceptions if there's a problem and we need to debug, so...
       try {
         // the dimensions here are taken from the poster images that we're
         // using, since they give the <video> elements their initial intrinsic
         // size.  This ensures that the right aspect ratios are calculated.
         // These are forced to 640x480, because it makes it visually easy to
         // validate that the showcase looks like the real app on a chine
@@ -131,16 +142,27 @@
             camera: {height: 480, orientation: 0, width: 640}
           },
           mediaConnected: options.mediaConnected,
           receivingScreenShare: !!options.receivingScreenShare,
           remoteVideoDimensions: {
             camera: {height: 480, orientation: 0, width: 640}
           },
           remoteVideoEnabled: options.remoteVideoEnabled,
+          // Override the matchMedia, this is so that the correct version is
+          // used for the frame.
+          //
+          // Currently, we use an icky hack, and the showcase conspires with
+          // react-frame-component to set iframe.contentWindow.matchMedia onto
+          // the store. Once React context matures a bit (somewhere between
+          // 0.14 and 1.0, apparently):
+          //
+          // https://facebook.github.io/react/blog/2015/02/24/streamlining-react-elements.html#solution-make-context-parent-based-instead-of-owner-based
+          //
+          // we should be able to use those to clean this up.
           matchMedia: contentWindow.matchMedia.bind(contentWindow),
           roomState: options.roomState,
           videoMuted: !!options.videoMuted
         };
 
         if (options.receivingScreenShare) {
           // Note that the image we're using had to be scaled a bit, and
           // it still ended up a bit narrower than the live thing that
@@ -180,16 +202,20 @@
     mediaConnected: false,
     roomState: ROOM_STATES.READY
   });
 
   var updatingActiveRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS
   });
 
+  var updatingMobileActiveRoomStore = makeActiveRoomStore({
+    roomState: ROOM_STATES.HAS_PARTICIPANTS
+  });
+
   var localFaceMuteRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS,
     videoMuted: true
   });
 
   var remoteFaceMuteRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS,
     remoteVideoEnabled: false,
@@ -267,25 +293,69 @@
     remoteVideoEnabled: false,
     mediaConnected: true
   });
   var desktopRemoteFaceMuteRoomStore = new loop.store.RoomStore(dispatcher, {
     mozLoop: navigator.mozLoop,
     activeRoomStore: desktopRemoteFaceMuteActiveRoomStore
   });
 
-  var conversationStore = new loop.store.ConversationStore(dispatcher, {
-    client: {},
-    mozLoop: navigator.mozLoop,
-    sdkDriver: mockSDK
-  });
   var textChatStore = new loop.store.TextChatStore(dispatcher, {
     sdkDriver: mockSDK
   });
 
+  /**
+   * Every view that uses an conversationStore needs its own; if they shared
+   * a conversation store, they'd interfere with each other.
+   *
+   * @param options
+   * @returns {loop.store.ConversationStore}
+   */
+  function makeConversationStore() {
+    var roomDispatcher = new loop.Dispatcher();
+
+    var store = new loop.store.ConversationStore(dispatcher, {
+      client: {},
+      mozLoop: navigator.mozLoop,
+      sdkDriver: mockSDK
+    });
+
+    store.forcedUpdate = function forcedUpdate(contentWindow) {
+      // Since this is called by setTimeout, we don't want to lose any
+      // exceptions if there's a problem and we need to debug, so...
+      try {
+        var newStoreState = {
+          // Override the matchMedia, this is so that the correct version is
+          // used for the frame.
+          //
+          // Currently, we use an icky hack, and the showcase conspires with
+          // react-frame-component to set iframe.contentWindow.matchMedia onto
+          // the store. Once React context matures a bit (somewhere between
+          // 0.14 and 1.0, apparently):
+          //
+          // https://facebook.github.io/react/blog/2015/02/24/streamlining-react-elements.html#solution-make-context-parent-based-instead-of-owner-based
+          //
+          // we should be able to use those to clean this up.
+          matchMedia: contentWindow.matchMedia.bind(contentWindow)
+        };
+
+        store.setStoreState(newStoreState);
+      } catch (ex) {
+        console.error("exception in forcedUpdate:", ex);
+      }
+    };
+
+    return store;
+  }
+
+  var conversationStores = [];
+  for (var index = 0; index < 5; index++) {
+    conversationStores[index] = makeConversationStore();
+  }
+
   // Update the text chat store with the room info.
   textChatStore.updateRoomInfo(new sharedActions.UpdateRoomInfo({
     roomName: "A Very Long Conversation Name",
     roomOwner: "fake",
     roomUrl: "http://showcase",
     urls: [{
       description: "A wonderful page!",
       location: "http://wonderful.invalid"
@@ -336,17 +406,17 @@
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
     contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
     message: "Cool",
     sentTimestamp: "2015-06-23T22:27:45.590Z"
   }));
 
   loop.store.StoreMixin.register({
     activeRoomStore: activeRoomStore,
-    conversationStore: conversationStore,
+    conversationStore: conversationStores[0],
     textChatStore: textChatStore
   });
 
   // Local mocks
 
   var mockMozLoopRooms = _.extend({}, navigator.mozLoop);
 
   var mockContact = {
@@ -355,24 +425,16 @@
       value: "smith@invalid.com"
     }]
   };
 
   var mockClient = {
     requestCallUrlInfo: noop
   };
 
-  var mockConversationModel = new loop.shared.models.ConversationModel({
-    callerId: "Mrs Jones",
-    urlCreationDate: (new Date() / 1000).toString()
-  }, {
-    sdk: mockSDK
-  });
-  mockConversationModel.startSession = noop;
-
   var mockWebSocket = new loop.CallConnectionWebSocket({
     url: "fake",
     callId: "fakeId",
     websocketToken: "fakeToken"
   });
 
   var notifications = new loop.shared.models.NotificationCollection();
   var errNotifications = new loop.shared.models.NotificationCollection();
@@ -758,93 +820,128 @@
 
           React.createElement(Section, {name: "CallFailedView"}, 
             React.createElement(Example, {dashed: true, 
                      style: {width: "300px", height: "272px"}, 
                      summary: "Call Failed - Incoming"}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(CallFailedView, {dispatcher: dispatcher, 
                                 outgoing: false, 
-                                store: conversationStore})
+                                store: conversationStores[0]})
               )
             ), 
             React.createElement(Example, {dashed: true, 
                      style: {width: "300px", height: "272px"}, 
                      summary: "Call Failed - Outgoing"}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(CallFailedView, {dispatcher: dispatcher, 
                                 outgoing: true, 
-                                store: conversationStore})
+                                store: conversationStores[1]})
               )
             ), 
             React.createElement(Example, {dashed: true, 
                      style: {width: "300px", height: "272px"}, 
                      summary: "Call Failed — with call URL error"}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(CallFailedView, {dispatcher: dispatcher, emailLinkError: true, 
                                 outgoing: true, 
-                                store: conversationStore})
+                                store: conversationStores[0]})
               )
             )
           ), 
 
           React.createElement(Section, {name: "OngoingConversationView"}, 
-            React.createElement(FramedExample, {height: 254, 
-                           summary: "Desktop ongoing conversation window", 
-                           width: 298}, 
+            React.createElement(FramedExample, {
+              dashed: true, 
+              height: 394, 
+              onContentsRendered: conversationStores[0].forcedUpdate, 
+              summary: "Desktop ongoing conversation window", 
+              width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(OngoingConversationView, {
                   audio: {enabled: true}, 
+                  conversationStore: conversationStores[0], 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mediaConnected: true, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   remoteVideoEnabled: true, 
                   video: {enabled: true}})
               )
             ), 
 
-            React.createElement(FramedExample, {height: 600, 
-                           summary: "Desktop ongoing conversation window large", 
-                           width: 800}, 
-                React.createElement("div", {className: "fx-embedded"}, 
-                  React.createElement(OngoingConversationView, {
-                    audio: {enabled: true}, 
-                    dispatcher: dispatcher, 
-                    localPosterUrl: "sample-img/video-screen-local.png", 
-                    mediaConnected: true, 
-                    remotePosterUrl: "sample-img/video-screen-remote.png", 
-                    remoteVideoEnabled: true, 
-                    video: {enabled: true}})
-                )
+            React.createElement(FramedExample, {
+              dashed: true, 
+              height: 400, 
+              onContentsRendered: conversationStores[1].forcedUpdate, 
+              summary: "Desktop ongoing conversation window (medium)", 
+              width: 600}, 
+              React.createElement("div", {className: "fx-embedded"}, 
+                React.createElement(OngoingConversationView, {
+                  audio: {enabled: true}, 
+                  conversationStore: conversationStores[1], 
+                  dispatcher: dispatcher, 
+                  localPosterUrl: "sample-img/video-screen-local.png", 
+                  mediaConnected: true, 
+                  remotePosterUrl: "sample-img/video-screen-remote.png", 
+                  remoteVideoEnabled: true, 
+                  video: {enabled: true}})
+              )
             ), 
 
-            React.createElement(FramedExample, {height: 254, 
+            React.createElement(FramedExample, {
+              height: 600, 
+              onContentsRendered: conversationStores[2].forcedUpdate, 
+              summary: "Desktop ongoing conversation window (large)", 
+              width: 800}, 
+              React.createElement("div", {className: "fx-embedded"}, 
+                React.createElement(OngoingConversationView, {
+                  audio: {enabled: true}, 
+                  conversationStore: conversationStores[2], 
+                  dispatcher: dispatcher, 
+                  localPosterUrl: "sample-img/video-screen-local.png", 
+                  mediaConnected: true, 
+                  remotePosterUrl: "sample-img/video-screen-remote.png", 
+                  remoteVideoEnabled: true, 
+                  video: {enabled: true}})
+              )
+            ), 
+
+            React.createElement(FramedExample, {
+              dashed: true, 
+              height: 394, 
+              onContentsRendered: conversationStores[3].forcedUpdate, 
               summary: "Desktop ongoing conversation window - local face mute", 
               width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(OngoingConversationView, {
                   audio: {enabled: true}, 
+                  conversationStore: conversationStores[3], 
                   dispatcher: dispatcher, 
+                  localPosterUrl: "sample-img/video-screen-local.png", 
                   mediaConnected: true, 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
                   remoteVideoEnabled: true, 
                   video: {enabled: false}})
               )
             ), 
 
-            React.createElement(FramedExample, {height: 254, 
+            React.createElement(FramedExample, {
+              dashed: true, height: 394, 
+              onContentsRendered: conversationStores[4].forcedUpdate, 
               summary: "Desktop ongoing conversation window - remote face mute", 
               width: 298}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(OngoingConversationView, {
                   audio: {enabled: true}, 
+                  conversationStore: conversationStores[4], 
                   dispatcher: dispatcher, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   mediaConnected: true, 
+                  remotePosterUrl: "sample-img/video-screen-remote.png", 
                   remoteVideoEnabled: false, 
                   video: {enabled: true}})
               )
             )
 
           ), 
 
           React.createElement(Section, {name: "FeedbackView"}, 
@@ -1166,22 +1263,22 @@
             )
           ), 
 
           React.createElement(Section, {name: "StandaloneRoomView (Mobile)"}, 
             React.createElement(FramedExample, {
               cssClass: "standalone", 
               dashed: true, 
               height: 480, 
-              onContentsRendered: updatingActiveRoomStore.forcedUpdate, 
+              onContentsRendered: updatingMobileActiveRoomStore.forcedUpdate, 
               summary: "Standalone room conversation (has-participants, 600x480)", 
               width: 600}, 
                 React.createElement("div", {className: "standalone"}, 
                   React.createElement(StandaloneRoomView, {
-                    activeRoomStore: updatingActiveRoomStore, 
+                    activeRoomStore: updatingMobileActiveRoomStore, 
                     dispatcher: dispatcher, 
                     isFirefox: true, 
                     localPosterUrl: "sample-img/video-screen-local.png", 
                     remotePosterUrl: "sample-img/video-screen-remote.png", 
                     roomState: ROOM_STATES.HAS_PARTICIPANTS})
                 )
             ), 
 
@@ -1277,17 +1374,17 @@
         setTimeout(waitForQueuedFrames, 500);
         return;
       }
       // Put the title back, in case views changed it.
       document.title = "Loop UI Components Showcase";
 
       // This simulates the mocha layout for errors which means we can run
       // this alongside our other unit tests but use the same harness.
-      var expectedWarningsCount = 23;
+      var expectedWarningsCount = 19;
       var warningsMismatch = caughtWarnings.length !== expectedWarningsCount;
       if (uncaughtError || warningsMismatch) {
         $("#results").append("<div class='failures'><em>" +
           ((uncaughtError && warningsMismatch) ? 2 : 1) + "</em></div>");
         if (warningsMismatch) {
           $("#results").append("<li class='test fail'>" +
             "<h2>Unexpected number of warnings detected in UI-Showcase</h2>" +
             "<pre class='error'>Got: " + caughtWarnings.length + "\n" +
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -70,23 +70,35 @@
     }
     window.removeEventListener(eventName, func);
   };
 
   loop.shared.mixins.setRootObject(rootObject);
 
   var dispatcher = new loop.Dispatcher();
 
-  var mockSDK = _.extend({
+  var MockSDK = function() {
+    dispatcher.register(this, [
+      "setupStreamElements"
+    ]);
+  };
+
+  MockSDK.prototype = {
+    setupStreamElements: function() {
+      // Dummy function to stop warnings.
+    },
+
     sendTextChatMessage: function(message) {
       dispatcher.dispatch(new loop.shared.actions.ReceivedTextChatMessage({
         message: message.message
       }));
     }
-  }, Backbone.Events);
+  };
+
+  var mockSDK = new MockSDK();
 
   /**
    * Every view that uses an activeRoomStore needs its own; if they shared
    * an active store, they'd interfere with each other.
    *
    * @param options
    * @returns {loop.store.ActiveRoomStore}
    */
@@ -111,17 +123,16 @@
       remoteVideoEnabled: options.remoteVideoEnabled,
       roomName: "A Very Long Conversation Name",
       roomState: options.roomState,
       used: !!options.roomUsed,
       videoMuted: !!options.videoMuted
     });
 
     store.forcedUpdate = function forcedUpdate(contentWindow) {
-
       // Since this is called by setTimeout, we don't want to lose any
       // exceptions if there's a problem and we need to debug, so...
       try {
         // the dimensions here are taken from the poster images that we're
         // using, since they give the <video> elements their initial intrinsic
         // size.  This ensures that the right aspect ratios are calculated.
         // These are forced to 640x480, because it makes it visually easy to
         // validate that the showcase looks like the real app on a chine
@@ -131,16 +142,27 @@
             camera: {height: 480, orientation: 0, width: 640}
           },
           mediaConnected: options.mediaConnected,
           receivingScreenShare: !!options.receivingScreenShare,
           remoteVideoDimensions: {
             camera: {height: 480, orientation: 0, width: 640}
           },
           remoteVideoEnabled: options.remoteVideoEnabled,
+          // Override the matchMedia, this is so that the correct version is
+          // used for the frame.
+          //
+          // Currently, we use an icky hack, and the showcase conspires with
+          // react-frame-component to set iframe.contentWindow.matchMedia onto
+          // the store. Once React context matures a bit (somewhere between
+          // 0.14 and 1.0, apparently):
+          //
+          // https://facebook.github.io/react/blog/2015/02/24/streamlining-react-elements.html#solution-make-context-parent-based-instead-of-owner-based
+          //
+          // we should be able to use those to clean this up.
           matchMedia: contentWindow.matchMedia.bind(contentWindow),
           roomState: options.roomState,
           videoMuted: !!options.videoMuted
         };
 
         if (options.receivingScreenShare) {
           // Note that the image we're using had to be scaled a bit, and
           // it still ended up a bit narrower than the live thing that
@@ -180,16 +202,20 @@
     mediaConnected: false,
     roomState: ROOM_STATES.READY
   });
 
   var updatingActiveRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS
   });
 
+  var updatingMobileActiveRoomStore = makeActiveRoomStore({
+    roomState: ROOM_STATES.HAS_PARTICIPANTS
+  });
+
   var localFaceMuteRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS,
     videoMuted: true
   });
 
   var remoteFaceMuteRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS,
     remoteVideoEnabled: false,
@@ -267,25 +293,69 @@
     remoteVideoEnabled: false,
     mediaConnected: true
   });
   var desktopRemoteFaceMuteRoomStore = new loop.store.RoomStore(dispatcher, {
     mozLoop: navigator.mozLoop,
     activeRoomStore: desktopRemoteFaceMuteActiveRoomStore
   });
 
-  var conversationStore = new loop.store.ConversationStore(dispatcher, {
-    client: {},
-    mozLoop: navigator.mozLoop,
-    sdkDriver: mockSDK
-  });
   var textChatStore = new loop.store.TextChatStore(dispatcher, {
     sdkDriver: mockSDK
   });
 
+  /**
+   * Every view that uses an conversationStore needs its own; if they shared
+   * a conversation store, they'd interfere with each other.
+   *
+   * @param options
+   * @returns {loop.store.ConversationStore}
+   */
+  function makeConversationStore() {
+    var roomDispatcher = new loop.Dispatcher();
+
+    var store = new loop.store.ConversationStore(dispatcher, {
+      client: {},
+      mozLoop: navigator.mozLoop,
+      sdkDriver: mockSDK
+    });
+
+    store.forcedUpdate = function forcedUpdate(contentWindow) {
+      // Since this is called by setTimeout, we don't want to lose any
+      // exceptions if there's a problem and we need to debug, so...
+      try {
+        var newStoreState = {
+          // Override the matchMedia, this is so that the correct version is
+          // used for the frame.
+          //
+          // Currently, we use an icky hack, and the showcase conspires with
+          // react-frame-component to set iframe.contentWindow.matchMedia onto
+          // the store. Once React context matures a bit (somewhere between
+          // 0.14 and 1.0, apparently):
+          //
+          // https://facebook.github.io/react/blog/2015/02/24/streamlining-react-elements.html#solution-make-context-parent-based-instead-of-owner-based
+          //
+          // we should be able to use those to clean this up.
+          matchMedia: contentWindow.matchMedia.bind(contentWindow)
+        };
+
+        store.setStoreState(newStoreState);
+      } catch (ex) {
+        console.error("exception in forcedUpdate:", ex);
+      }
+    };
+
+    return store;
+  }
+
+  var conversationStores = [];
+  for (var index = 0; index < 5; index++) {
+    conversationStores[index] = makeConversationStore();
+  }
+
   // Update the text chat store with the room info.
   textChatStore.updateRoomInfo(new sharedActions.UpdateRoomInfo({
     roomName: "A Very Long Conversation Name",
     roomOwner: "fake",
     roomUrl: "http://showcase",
     urls: [{
       description: "A wonderful page!",
       location: "http://wonderful.invalid"
@@ -336,17 +406,17 @@
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
     contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
     message: "Cool",
     sentTimestamp: "2015-06-23T22:27:45.590Z"
   }));
 
   loop.store.StoreMixin.register({
     activeRoomStore: activeRoomStore,
-    conversationStore: conversationStore,
+    conversationStore: conversationStores[0],
     textChatStore: textChatStore
   });
 
   // Local mocks
 
   var mockMozLoopRooms = _.extend({}, navigator.mozLoop);
 
   var mockContact = {
@@ -355,24 +425,16 @@
       value: "smith@invalid.com"
     }]
   };
 
   var mockClient = {
     requestCallUrlInfo: noop
   };
 
-  var mockConversationModel = new loop.shared.models.ConversationModel({
-    callerId: "Mrs Jones",
-    urlCreationDate: (new Date() / 1000).toString()
-  }, {
-    sdk: mockSDK
-  });
-  mockConversationModel.startSession = noop;
-
   var mockWebSocket = new loop.CallConnectionWebSocket({
     url: "fake",
     callId: "fakeId",
     websocketToken: "fakeToken"
   });
 
   var notifications = new loop.shared.models.NotificationCollection();
   var errNotifications = new loop.shared.models.NotificationCollection();
@@ -758,93 +820,128 @@
 
           <Section name="CallFailedView">
             <Example dashed={true}
                      style={{width: "300px", height: "272px"}}
                      summary="Call Failed - Incoming">
               <div className="fx-embedded">
                 <CallFailedView dispatcher={dispatcher}
                                 outgoing={false}
-                                store={conversationStore} />
+                                store={conversationStores[0]} />
               </div>
             </Example>
             <Example dashed={true}
                      style={{width: "300px", height: "272px"}}
                      summary="Call Failed - Outgoing">
               <div className="fx-embedded">
                 <CallFailedView dispatcher={dispatcher}
                                 outgoing={true}
-                                store={conversationStore} />
+                                store={conversationStores[1]} />
               </div>
             </Example>
             <Example dashed={true}
                      style={{width: "300px", height: "272px"}}
                      summary="Call Failed — with call URL error">
               <div className="fx-embedded">
                 <CallFailedView dispatcher={dispatcher} emailLinkError={true}
                                 outgoing={true}
-                                store={conversationStore} />
+                                store={conversationStores[0]} />
               </div>
             </Example>
           </Section>
 
           <Section name="OngoingConversationView">
-            <FramedExample height={254}
-                           summary="Desktop ongoing conversation window"
-                           width={298}>
+            <FramedExample
+              dashed={true}
+              height={394}
+              onContentsRendered={conversationStores[0].forcedUpdate}
+              summary="Desktop ongoing conversation window"
+              width={298}>
               <div className="fx-embedded">
                 <OngoingConversationView
                   audio={{enabled: true}}
+                  conversationStore={conversationStores[0]}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mediaConnected={true}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   remoteVideoEnabled={true}
                   video={{enabled: true}} />
               </div>
             </FramedExample>
 
-            <FramedExample height={600}
-                           summary="Desktop ongoing conversation window large"
-                           width={800}>
-                <div className="fx-embedded">
-                  <OngoingConversationView
-                    audio={{enabled: true}}
-                    dispatcher={dispatcher}
-                    localPosterUrl="sample-img/video-screen-local.png"
-                    mediaConnected={true}
-                    remotePosterUrl="sample-img/video-screen-remote.png"
-                    remoteVideoEnabled={true}
-                    video={{enabled: true}} />
-                </div>
+            <FramedExample
+              dashed={true}
+              height={400}
+              onContentsRendered={conversationStores[1].forcedUpdate}
+              summary="Desktop ongoing conversation window (medium)"
+              width={600}>
+              <div className="fx-embedded">
+                <OngoingConversationView
+                  audio={{enabled: true}}
+                  conversationStore={conversationStores[1]}
+                  dispatcher={dispatcher}
+                  localPosterUrl="sample-img/video-screen-local.png"
+                  mediaConnected={true}
+                  remotePosterUrl="sample-img/video-screen-remote.png"
+                  remoteVideoEnabled={true}
+                  video={{enabled: true}} />
+              </div>
             </FramedExample>
 
-            <FramedExample height={254}
+            <FramedExample
+              height={600}
+              onContentsRendered={conversationStores[2].forcedUpdate}
+              summary="Desktop ongoing conversation window (large)"
+              width={800}>
+              <div className="fx-embedded">
+                <OngoingConversationView
+                  audio={{enabled: true}}
+                  conversationStore={conversationStores[2]}
+                  dispatcher={dispatcher}
+                  localPosterUrl="sample-img/video-screen-local.png"
+                  mediaConnected={true}
+                  remotePosterUrl="sample-img/video-screen-remote.png"
+                  remoteVideoEnabled={true}
+                  video={{enabled: true}} />
+              </div>
+            </FramedExample>
+
+            <FramedExample
+              dashed={true}
+              height={394}
+              onContentsRendered={conversationStores[3].forcedUpdate}
               summary="Desktop ongoing conversation window - local face mute"
               width={298} >
               <div className="fx-embedded">
                 <OngoingConversationView
                   audio={{enabled: true}}
+                  conversationStore={conversationStores[3]}
                   dispatcher={dispatcher}
+                  localPosterUrl="sample-img/video-screen-local.png"
                   mediaConnected={true}
                   remotePosterUrl="sample-img/video-screen-remote.png"
                   remoteVideoEnabled={true}
                   video={{enabled: false}} />
               </div>
             </FramedExample>
 
-            <FramedExample height={254}
+            <FramedExample
+              dashed={true} height={394}
+              onContentsRendered={conversationStores[4].forcedUpdate}
               summary="Desktop ongoing conversation window - remote face mute"
               width={298} >
               <div className="fx-embedded">
                 <OngoingConversationView
                   audio={{enabled: true}}
+                  conversationStore={conversationStores[4]}
                   dispatcher={dispatcher}
                   localPosterUrl="sample-img/video-screen-local.png"
                   mediaConnected={true}
+                  remotePosterUrl="sample-img/video-screen-remote.png"
                   remoteVideoEnabled={false}
                   video={{enabled: true}} />
               </div>
             </FramedExample>
 
           </Section>
 
           <Section name="FeedbackView">
@@ -1166,22 +1263,22 @@
             </FramedExample>
           </Section>
 
           <Section name="StandaloneRoomView (Mobile)">
             <FramedExample
               cssClass="standalone"
               dashed={true}
               height={480}
-              onContentsRendered={updatingActiveRoomStore.forcedUpdate}
+              onContentsRendered={updatingMobileActiveRoomStore.forcedUpdate}
               summary="Standalone room conversation (has-participants, 600x480)"
               width={600}>
                 <div className="standalone">
                   <StandaloneRoomView
-                    activeRoomStore={updatingActiveRoomStore}
+                    activeRoomStore={updatingMobileActiveRoomStore}
                     dispatcher={dispatcher}
                     isFirefox={true}
                     localPosterUrl="sample-img/video-screen-local.png"
                     remotePosterUrl="sample-img/video-screen-remote.png"
                     roomState={ROOM_STATES.HAS_PARTICIPANTS} />
                 </div>
             </FramedExample>
 
@@ -1277,17 +1374,17 @@
         setTimeout(waitForQueuedFrames, 500);
         return;
       }
       // Put the title back, in case views changed it.
       document.title = "Loop UI Components Showcase";
 
       // This simulates the mocha layout for errors which means we can run
       // this alongside our other unit tests but use the same harness.
-      var expectedWarningsCount = 23;
+      var expectedWarningsCount = 19;
       var warningsMismatch = caughtWarnings.length !== expectedWarningsCount;
       if (uncaughtError || warningsMismatch) {
         $("#results").append("<div class='failures'><em>" +
           ((uncaughtError && warningsMismatch) ? 2 : 1) + "</em></div>");
         if (warningsMismatch) {
           $("#results").append("<li class='test fail'>" +
             "<h2>Unexpected number of warnings detected in UI-Showcase</h2>" +
             "<pre class='error'>Got: " + caughtWarnings.length + "\n" +
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -201,16 +201,18 @@ hangup_button_caption2=Exit
 mute_local_audio_button_title=Mute your audio
 unmute_local_audio_button_title=Unmute your audio
 mute_local_video_button_title=Mute your video
 unmute_local_video_button_title=Unmute your video
 active_screenshare_button_title=Stop sharing
 inactive_screenshare_button_title=Share your screen
 share_tabs_button_title2=Share your Tabs
 share_windows_button_title=Share other Windows
+self_view_hidden_message=Self-view hidden but still being sent; resize window to show
+
 
 ## LOCALIZATION NOTE (call_with_contact_title): The title displayed
 ## when calling a contact. Don't translate the part between {{..}} because
 ## this will be replaced by the contact's name.
 call_with_contact_title=Conversation with {{contactName}}
 
 # Outgoing conversation