Bug 1171933-Reimplement spinners that Hello lost after markdown extraction, r=dmose
authorAndrei Oprea <andrei.br92@gmail.com>
Thu, 18 Jun 2015 23:05:33 -0700
changeset 267747 25d7035e9fcd95b06713a468c90f8c468201c43e
parent 267746 134465d1ec047c131261d2f176ad8935f42f3bb4
child 267748 cfdfff0c69632d142ba815411f4b95aa83679e2d
push id4932
push userjlund@mozilla.com
push dateMon, 10 Aug 2015 18:23:06 +0000
treeherdermozilla-esr52@6dd5a4f5f745 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdmose
bugs1171933
milestone41.0a1
Bug 1171933-Reimplement spinners that Hello lost after markdown extraction, r=dmose
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/spinner.svg
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/content/shared/js/otSdkDriver.js
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/jar.mn
browser/components/loop/standalone/content/js/standaloneRoomViews.js
browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
browser/components/loop/test/desktop-local/roomViews_test.js
browser/components/loop/test/shared/otSdkDriver_test.js
browser/components/loop/test/standalone/standaloneRoomViews_test.js
browser/components/loop/ui/ui-showcase.css
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -643,16 +643,19 @@ loop.roomViews = (function(mozL10n) {
       );
     },
 
     /**
      * Works out if remote video should be rended or not, depending on the
      * room state and other flags.
      *
      * @return {Boolean} True if remote video should be rended.
+     *
+     * XXX Refactor shouldRenderRemoteVideo & shouldRenderLoading into one fn
+     *     that returns an enum
      */
     shouldRenderRemoteVideo: function() {
       switch(this.state.roomState) {
         case ROOM_STATES.HAS_PARTICIPANTS:
           if (this.state.remoteVideoEnabled) {
             return true;
           }
 
@@ -679,16 +682,41 @@ loop.roomViews = (function(mozL10n) {
 
         default:
           console.warn("DesktopRoomConversationView.shouldRenderRemoteVideo:" +
             " unexpected roomState: ", this.state.roomState);
           return true;
       }
     },
 
+    /**
+     * 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
+     */
+    _shouldRenderLocalLoading: function () {
+      return this.state.roomState === ROOM_STATES.MEDIA_WAIT &&
+             !this.state.localSrcVideoObject;
+    },
+
+    /**
+     * 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
+     */
+    _shouldRenderRemoteLoading: function() {
+      return this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS &&
+             !this.state.remoteSrcVideoObject &&
+             !this.state.mediaConnected;
+    },
+
     render: function() {
       if (this.state.roomName) {
         this.setTitle(this.state.roomName);
       }
 
       var localStreamClasses = React.addons.classSet({
         local: true,
         "local-stream": true,
@@ -737,23 +765,25 @@ loop.roomViews = (function(mozL10n) {
                 socialShareProviders: this.state.socialShareProviders}), 
               React.createElement("div", {className: "video-layout-wrapper"}, 
                 React.createElement("div", {className: "conversation room-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(), 
                           posterUrl: this.props.remotePosterUrl, 
+                          isLoading: this._shouldRenderRemoteLoading(), 
                           mediaType: "remote", 
                           srcVideoObject: this.state.remoteSrcVideoObject})
                       )
                     ), 
                     React.createElement("div", {className: localStreamClasses}, 
                       React.createElement(sharedViews.MediaView, {displayAvatar: this.state.videoMuted, 
                         posterUrl: this.props.localPosterUrl, 
+                        isLoading: this._shouldRenderLocalLoading(), 
                         mediaType: "local", 
                         srcVideoObject: this.state.localSrcVideoObject})
                     )
                   ), 
                   React.createElement(sharedViews.ConversationToolbar, {
                     dispatcher: this.props.dispatcher, 
                     video: {enabled: !this.state.videoMuted, visible: true}, 
                     audio: {enabled: !this.state.audioMuted, visible: true}, 
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -643,16 +643,19 @@ loop.roomViews = (function(mozL10n) {
       );
     },
 
     /**
      * Works out if remote video should be rended or not, depending on the
      * room state and other flags.
      *
      * @return {Boolean} True if remote video should be rended.
+     *
+     * XXX Refactor shouldRenderRemoteVideo & shouldRenderLoading into one fn
+     *     that returns an enum
      */
     shouldRenderRemoteVideo: function() {
       switch(this.state.roomState) {
         case ROOM_STATES.HAS_PARTICIPANTS:
           if (this.state.remoteVideoEnabled) {
             return true;
           }
 
@@ -679,16 +682,41 @@ loop.roomViews = (function(mozL10n) {
 
         default:
           console.warn("DesktopRoomConversationView.shouldRenderRemoteVideo:" +
             " unexpected roomState: ", this.state.roomState);
           return true;
       }
     },
 
+    /**
+     * 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
+     */
+    _shouldRenderLocalLoading: function () {
+      return this.state.roomState === ROOM_STATES.MEDIA_WAIT &&
+             !this.state.localSrcVideoObject;
+    },
+
+    /**
+     * 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
+     */
+    _shouldRenderRemoteLoading: function() {
+      return this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS &&
+             !this.state.remoteSrcVideoObject &&
+             !this.state.mediaConnected;
+    },
+
     render: function() {
       if (this.state.roomName) {
         this.setTitle(this.state.roomName);
       }
 
       var localStreamClasses = React.addons.classSet({
         local: true,
         "local-stream": true,
@@ -737,23 +765,25 @@ loop.roomViews = (function(mozL10n) {
                 socialShareProviders={this.state.socialShareProviders} />
               <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 focus-stream">
                         <sharedViews.MediaView displayAvatar={!this.shouldRenderRemoteVideo()}
                           posterUrl={this.props.remotePosterUrl}
+                          isLoading={this._shouldRenderRemoteLoading()}
                           mediaType="remote"
                           srcVideoObject={this.state.remoteSrcVideoObject} />
                       </div>
                     </div>
                     <div className={localStreamClasses}>
                       <sharedViews.MediaView displayAvatar={this.state.videoMuted}
                         posterUrl={this.props.localPosterUrl}
+                        isLoading={this._shouldRenderLocalLoading()}
                         mediaType="local"
                         srcVideoObject={this.state.localSrcVideoObject} />
                     </div>
                   </div>
                   <sharedViews.ConversationToolbar
                     dispatcher={this.props.dispatcher}
                     video={{enabled: !this.state.videoMuted, visible: true}}
                     audio={{enabled: !this.state.audioMuted, visible: true}}
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -548,16 +548,49 @@
   /*
    * Expand to fill the available space, since there is no video any
    * intrinsic width. XXX should really change to an <img> for clarity
    */
   height: 100%;
   width: 100%;
 }
 
+/*
+ * Used to center the loading spinner
+ */
+.focus-stream, .remote {
+  position: relative;
+}
+
+.loading-stream {
+  /* vertical and horizontal center */
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  margin-left: -50px;
+  margin-top: -50px;
+  width: 100px;
+  height: 100px;
+
+  /* place the animation */
+  background-image: url("../img/spinner.svg");
+  background-position: center;
+  background-repeat: no-repeat;
+  background-size: 40%;
+
+  /* 12 is the number of lines in the spinner image */
+  animation: rotate-spinner 1s steps(12, end) infinite;
+}
+
+@keyframes rotate-spinner {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
 .conversation .local .avatar {
   position: absolute;
   z-index: 1;
 }
 
 .remote .avatar {
   /* make visually distinct from local avatar */
   opacity: 0.25;
@@ -1591,8 +1624,16 @@ html[dir="rtl"] .standalone .room-conver
   width: 100%;
   height: 100%;
 }
 
 .screen-share-video {
   width: 100%;
   height: 100%;
 }
+
+/* Make sure the loading spinner always gets the same background */
+.loading-background {
+  background: black;
+  position: absolute;
+  width: 100%;
+  height: 100%;
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/spinner.svg
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="171px" height="171px" viewBox="0 0 171 171" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
+    <!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
+    <title>FX_Hello-glyph-spinner-16x16</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="spinner" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
+        <g id="hello-spinner" sketch:type="MSLayerGroup">
+            <g id="Group" sketch:type="MSShapeGroup">
+                <path d="M94.05,34.24275 C94.05,38.966625 90.223875,42.79275 85.5,42.79275 L85.5,42.79275 C80.776125,42.79275 76.95,38.966625 76.95,34.24275 L76.95,8.55 C76.95,3.826125 80.776125,0 85.5,0 L85.5,0 C90.223875,0 94.05,3.826125 94.05,8.55 L94.05,34.24275 L94.05,34.24275 Z" id="Shape" fill="#A4A4A4"></path>
+                <path d="M94.05,162.45 C94.05,167.173875 90.223875,171 85.5,171 L85.5,171 C80.776125,171 76.95,167.173875 76.95,162.45 L76.95,136.75725 C76.95,132.033375 80.776125,128.20725 85.5,128.20725 L85.5,128.20725 C90.223875,128.20725 94.05,132.033375 94.05,136.75725 L94.05,162.45 L94.05,162.45 Z" id="Shape" fill="#FFFFFF"></path>
+                <path d="M34.24275,76.95 C38.966625,76.95 42.79275,80.776125 42.79275,85.5 L42.79275,85.5 C42.79275,90.223875 38.966625,94.05 34.24275,94.05 L8.55,94.05 C3.826125,94.05 0,90.223875 0,85.5 L0,85.5 C0,80.776125 3.826125,76.95 8.55,76.95 L34.24275,76.95 L34.24275,76.95 Z" id="Shape" fill="#616161"></path>
+                <path d="M162.45,76.95 C167.173875,76.95 171,80.776125 171,85.5 L171,85.5 C171,90.223875 167.173875,94.05 162.45,94.05 L136.75725,94.05 C132.033375,94.05 128.20725,90.223875 128.20725,85.5 L128.20725,85.5 C128.20725,80.776125 132.033375,76.95 136.75725,76.95 L162.45,76.95 L162.45,76.95 Z" id="Shape" fill="#C5C5C5"></path>
+                <path d="M36.8398125,103.743562 C40.933125,101.381625 46.1593125,102.781688 48.52125,106.864312 L48.52125,106.864312 C50.8831875,110.957625 49.483125,116.183813 45.4005,118.54575 L23.149125,131.402812 C19.0558125,133.76475 13.829625,132.364688 11.4676875,128.271375 L11.4676875,128.271375 C9.10575,124.18875 10.5058125,118.951875 14.5884375,116.600625 L36.8398125,103.743562 L36.8398125,103.743562 Z" id="Shape" fill="#4F4F4F"></path>
+                <path d="M147.861562,39.607875 C151.944187,37.2459375 157.181062,38.646 159.543,42.728625 L159.543,42.728625 C161.904938,46.81125 160.504875,52.048125 156.42225,54.399375 L134.170875,67.2564375 C130.077563,69.618375 124.851375,68.2183125 122.489438,64.1356875 L122.489438,64.1356875 C120.1275,60.0530625 121.527563,54.8161875 125.610188,52.4649375 L147.861562,39.607875 L147.861562,39.607875 Z" id="Shape" fill="#BABABA"></path>
+                <path d="M103.732875,134.160188 C101.370938,130.077563 102.771,124.840688 106.853625,122.489438 L106.853625,122.489438 C110.93625,120.1275 116.173125,121.527563 118.535063,125.620875 L131.392125,147.87225 C133.754063,151.954875 132.354,157.19175 128.271375,159.543 L128.271375,159.543 C124.18875,161.904938 118.951875,160.504875 116.589938,156.42225 L103.732875,134.160188 L103.732875,134.160188 Z" id="Shape" fill="#E0E0E0"></path>
+                <path d="M39.607875,23.149125 C37.2459375,19.0665 38.646,13.829625 42.728625,11.4676875 L42.728625,11.4676875 C46.81125,9.10575 52.048125,10.5058125 54.4100625,14.599125 L67.267125,36.8505 C69.618375,40.933125 68.2183125,46.1593125 64.1356875,48.52125 L64.1356875,48.52125 C60.042375,50.8831875 54.8161875,49.483125 52.45425,45.4005 L39.607875,23.149125 L39.607875,23.149125 Z" id="Shape" fill="#989898"></path>
+                <path d="M52.5076875,125.652938 C54.8589375,121.559625 60.085125,120.159563 64.1784375,122.510813 L64.1784375,122.510813 C68.27175,124.862063 69.6718125,130.08825 67.3205625,134.181563 L54.4955625,156.454313 C52.1443125,160.547625 46.9074375,161.947688 42.8248125,159.596438 L42.8248125,159.596438 C38.7315,157.245187 37.3314375,152.019 39.6826875,147.925688 L52.5076875,125.652938 L52.5076875,125.652938 Z" id="Shape" fill="#3E3E3E"></path>
+                <path d="M116.504437,14.556375 C118.866375,10.4630625 124.081875,9.063 128.175187,11.41425 L128.175187,11.41425 C132.2685,13.7655 133.668563,19.002375 131.317313,23.085 L118.492313,45.35775 C116.130375,49.4510625 110.904188,50.851125 106.821563,48.499875 L106.821563,48.499875 C102.72825,46.148625 101.328188,40.9224375 103.679438,36.829125 L116.504437,14.556375 L116.504437,14.556375 Z" id="Shape" fill="#B0B0B0"></path>
+                <path d="M125.64225,118.503 C121.548937,116.141062 120.148875,110.914875 122.500125,106.83225 L122.500125,106.83225 C124.851375,102.738937 130.077562,101.338875 134.170875,103.690125 L156.432937,116.515125 C160.52625,118.877062 161.926312,124.10325 159.575063,128.185875 L159.575063,128.185875 C157.223812,132.279187 151.997625,133.67925 147.904313,131.328 L125.64225,118.503 L125.64225,118.503 Z" id="Shape" fill="#CECECE"></path>
+                <path d="M14.556375,54.4955625 C10.4630625,52.1443125 9.063,46.918125 11.41425,42.8248125 L11.41425,42.8248125 C13.7761875,38.7315 19.002375,37.3314375 23.085,39.6826875 L45.35775,52.5076875 C49.4510625,54.869625 50.851125,60.0958125 48.499875,64.1784375 L48.499875,64.1784375 C46.1379375,68.2824375 40.91175,69.6825 36.8184375,67.33125 L14.556375,54.4955625 L14.556375,54.4955625 Z" id="Shape" fill="#7C7C7C"></path>
+            </g>
+        </g>
+    </g>
+</svg>
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -285,18 +285,17 @@ loop.shared.actions = (function() {
     ScreenSharingState: Action.define("screenSharingState", {
       // One of loop.shared.utils.SCREEN_SHARE_STATES.
       state: String
     }),
 
     /**
      * Used to notify that a shared screen is being received (or not).
      *
-     * XXX this is going to need to be split into two actions so when
-     * can display a spinner when the screen share is pending (bug 1171933)
+     * XXX this should be split into multiple actions to make the code clearer.
      */
     ReceivingScreenShare: Action.define("receivingScreenShare", {
       receiving: Boolean
       // srcVideoObject: Object (only present if receiving is true)
     }),
 
     /**
      * Creates a new room.
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -607,18 +607,17 @@ loop.store.ActiveRoomStore = (function()
       this._mozLoop.setScreenShareState(
         this.getStoreState().windowId,
         actionData.state === SCREEN_SHARE_STATES.ACTIVE);
     },
 
     /**
      * Used to note the current state of receiving screenshare data.
      *
-     * XXX this is going to need to be split into two actions so when
-     * can display a spinner when the screen share is pending (bug 1171933)
+     * XXX this should be split into multiple actions to make the code clearer.
      */
     receivingScreenShare: function(actionData) {
       if (!actionData.receiving &&
           this.getStoreState().remoteVideoDimensions.screen) {
         // Remove the remote video dimensions for type screen as we're not
         // getting the share anymore.
         var newDimensions = _.extend(this.getStoreState().remoteVideoDimensions);
         delete newDimensions.screen;
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -507,26 +507,19 @@ loop.OTSdkDriver = (function() {
      * the stream, and notifying the stores that a share is being
      * received.
      *
      * @param {Stream} stream The SDK Stream:
      * https://tokbox.com/opentok/libraries/client/js/reference/Stream.html
      */
     _handleRemoteScreenShareCreated: function(stream) {
       // Let the stores know first so they can update the display.
-      // XXX We do want to do this - we want them to start re-arranging the
-      // display so that we can a) indicate connecting, b) be ready for
-      // when we get the stream. However, we're currently limited by the fact
-      // the view calculations require the remote (aka screen share) element to
-      // be present and laid out. Hence, we need to drop this for the time being,
-      // and let the client know via _onScreenShareSubscribeCompleted.
-      // Bug 1171933 is going to look at fixing this.
-      // this.dispatcher.dispatch(new sharedActions.ReceivingScreenShare({
-      //  receiving: true
-      // }));
+      this.dispatcher.dispatch(new sharedActions.ReceivingScreenShare({
+        receiving: true
+      }));
 
       // There's no audio for screen shares so we don't need to worry about mute.
       this._mockScreenShareEl = document.createElement("div");
       this.session.subscribe(stream, this._mockScreenShareEl,
         this._getCopyPublisherConfig,
         this._onScreenShareSubscribeCompleted.bind(this));
     },
 
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -694,16 +694,31 @@ loop.shared.views = (function(_, l10n) {
     mixins: [React.addons.PureRenderMixin],
 
     render: function() {
         return React.createElement("div", {className: "avatar"});
     }
   });
 
   /**
+   * Renders a loading spinner for when video content is not yet available.
+   */
+  var LoadingView = React.createClass({displayName: "LoadingView",
+    mixins: [React.addons.PureRenderMixin],
+
+    render: function() {
+        return (
+          React.createElement("div", {className: "loading-background"}, 
+            React.createElement("div", {className: "loading-stream"})
+          )
+        );
+    }
+  });
+
+  /**
    * Renders a url that's part of context on the display.
    *
    * @property {Boolean} allowClick         Set to true to allow the url to be clicked. If this
    *                                        is specified, then 'dispatcher' is also required.
    * @property {String}  description        The description for the context url.
    * @property {loop.Dispatcher} dispatcher
    * @property {Boolean} showContextTitle   Whether or not to show the "Let's talk about" title.
    * @property {String}  thumbnail          The thumbnail url (expected to be a data url) to
@@ -793,16 +808,17 @@ loop.shared.views = (function(_, l10n) {
    */
   var MediaView = React.createClass({displayName: "MediaView",
     // srcVideoObject should be ok for a shallow comparison, so we are safe
     // to use the pure render mixin here.
     mixins: [React.addons.PureRenderMixin],
 
     PropTypes: {
       displayAvatar: React.PropTypes.bool.isRequired,
+      isLoading: React.PropTypes.bool.isRequired,
       posterUrl: React.PropTypes.string,
       // Expecting "local" or "remote".
       mediaType: React.PropTypes.string.isRequired,
       srcVideoObject: React.PropTypes.object
     },
 
     componentDidMount: function() {
       if (!this.props.displayAvatar) {
@@ -846,28 +862,33 @@ loop.shared.views = (function(_, l10n) {
         attrName = "srcObject";
       } else if ("mozSrcObject" in videoElement) {
         // mozSrcObject is for Firefox
         attrName = "mozSrcObject";
       } else if ("src" in videoElement) {
         // src is for Chrome.
         attrName = "src";
       } else {
-        console.error("Error attaching stream to element - no supported attribute found");
+        console.error("Error attaching stream to element - no supported" +
+                      "attribute found");
         return;
       }
 
       // If the object hasn't changed it, then don't reattach it.
       if (videoElement[attrName] !== srcVideoObject[attrName]) {
         videoElement[attrName] = srcVideoObject[attrName];
       }
       videoElement.play();
     },
 
     render: function() {
+      if (this.props.isLoading) {
+        return React.createElement(LoadingView, null);
+      }
+
       if (this.props.displayAvatar) {
         return React.createElement(AvatarView, null);
       }
 
       if (!this.props.srcVideoObject && !this.props.posterUrl) {
         return React.createElement("div", {className: "no-video"});
       }
 
@@ -898,12 +919,13 @@ loop.shared.views = (function(_, l10n) {
     Button: Button,
     ButtonGroup: ButtonGroup,
     Checkbox: Checkbox,
     ContextUrlView: ContextUrlView,
     ConversationView: ConversationView,
     ConversationToolbar: ConversationToolbar,
     MediaControlButton: MediaControlButton,
     MediaView: MediaView,
+    LoadingView: LoadingView,
     ScreenShareControlButton: ScreenShareControlButton,
     NotificationListView: NotificationListView
   };
 })(_, navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -694,16 +694,31 @@ loop.shared.views = (function(_, l10n) {
     mixins: [React.addons.PureRenderMixin],
 
     render: function() {
         return <div className="avatar"/>;
     }
   });
 
   /**
+   * Renders a loading spinner for when video content is not yet available.
+   */
+  var LoadingView = React.createClass({
+    mixins: [React.addons.PureRenderMixin],
+
+    render: function() {
+        return (
+          <div className="loading-background">
+            <div className="loading-stream"/>
+          </div>
+        );
+    }
+  });
+
+  /**
    * Renders a url that's part of context on the display.
    *
    * @property {Boolean} allowClick         Set to true to allow the url to be clicked. If this
    *                                        is specified, then 'dispatcher' is also required.
    * @property {String}  description        The description for the context url.
    * @property {loop.Dispatcher} dispatcher
    * @property {Boolean} showContextTitle   Whether or not to show the "Let's talk about" title.
    * @property {String}  thumbnail          The thumbnail url (expected to be a data url) to
@@ -793,16 +808,17 @@ loop.shared.views = (function(_, l10n) {
    */
   var MediaView = React.createClass({
     // srcVideoObject should be ok for a shallow comparison, so we are safe
     // to use the pure render mixin here.
     mixins: [React.addons.PureRenderMixin],
 
     PropTypes: {
       displayAvatar: React.PropTypes.bool.isRequired,
+      isLoading: React.PropTypes.bool.isRequired,
       posterUrl: React.PropTypes.string,
       // Expecting "local" or "remote".
       mediaType: React.PropTypes.string.isRequired,
       srcVideoObject: React.PropTypes.object
     },
 
     componentDidMount: function() {
       if (!this.props.displayAvatar) {
@@ -846,28 +862,33 @@ loop.shared.views = (function(_, l10n) {
         attrName = "srcObject";
       } else if ("mozSrcObject" in videoElement) {
         // mozSrcObject is for Firefox
         attrName = "mozSrcObject";
       } else if ("src" in videoElement) {
         // src is for Chrome.
         attrName = "src";
       } else {
-        console.error("Error attaching stream to element - no supported attribute found");
+        console.error("Error attaching stream to element - no supported" +
+                      "attribute found");
         return;
       }
 
       // If the object hasn't changed it, then don't reattach it.
       if (videoElement[attrName] !== srcVideoObject[attrName]) {
         videoElement[attrName] = srcVideoObject[attrName];
       }
       videoElement.play();
     },
 
     render: function() {
+      if (this.props.isLoading) {
+        return <LoadingView />;
+      }
+
       if (this.props.displayAvatar) {
         return <AvatarView />;
       }
 
       if (!this.props.srcVideoObject && !this.props.posterUrl) {
         return <div className="no-video"/>;
       }
 
@@ -898,12 +919,13 @@ loop.shared.views = (function(_, l10n) {
     Button: Button,
     ButtonGroup: ButtonGroup,
     Checkbox: Checkbox,
     ContextUrlView: ContextUrlView,
     ConversationView: ConversationView,
     ConversationToolbar: ConversationToolbar,
     MediaControlButton: MediaControlButton,
     MediaView: MediaView,
+    LoadingView: LoadingView,
     ScreenShareControlButton: ScreenShareControlButton,
     NotificationListView: NotificationListView
   };
 })(_, navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -30,16 +30,19 @@ browser.jar:
   content/browser/loop/shared/css/common.css        (content/shared/css/common.css)
   content/browser/loop/shared/css/conversation.css  (content/shared/css/conversation.css)
 
   # Shared images
   content/browser/loop/shared/img/happy.png                     (content/shared/img/happy.png)
   content/browser/loop/shared/img/sad.png                       (content/shared/img/sad.png)
   content/browser/loop/shared/img/icon_32.png                   (content/shared/img/icon_32.png)
   content/browser/loop/shared/img/icon_64.png                   (content/shared/img/icon_64.png)
+  content/browser/loop/shared/img/spinner.svg                   (content/shared/img/spinner.svg)
+  # XXX could get rid of the png spinner usages and replace them with the svg
+  # one?
   content/browser/loop/shared/img/spinner.png                   (content/shared/img/spinner.png)
   content/browser/loop/shared/img/spinner@2x.png                (content/shared/img/spinner@2x.png)
   content/browser/loop/shared/img/audio-inverse-14x14.png       (content/shared/img/audio-inverse-14x14.png)
   content/browser/loop/shared/img/audio-inverse-14x14@2x.png    (content/shared/img/audio-inverse-14x14@2x.png)
   content/browser/loop/shared/img/facemute-14x14.png            (content/shared/img/facemute-14x14.png)
   content/browser/loop/shared/img/facemute-14x14@2x.png         (content/shared/img/facemute-14x14@2x.png)
   content/browser/loop/shared/img/hangup-inverse-14x14.png      (content/shared/img/hangup-inverse-14x14.png)
   content/browser/loop/shared/img/hangup-inverse-14x14@2x.png   (content/shared/img/hangup-inverse-14x14@2x.png)
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -324,16 +324,19 @@ loop.standaloneRoomViews = (function(moz
              this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS;
     },
 
     /**
      * Works out if remote video should be rended or not, depending on the
      * room state and other flags.
      *
      * @return {Boolean} True if remote video should be rended.
+     *
+     * XXX Refactor shouldRenderRemoteVideo & shouldRenderLoading to remove
+     *     overlapping cases.
      */
     shouldRenderRemoteVideo: function() {
       switch(this.state.roomState) {
         case ROOM_STATES.HAS_PARTICIPANTS:
           if (this.state.remoteVideoEnabled) {
             return true;
           }
 
@@ -362,16 +365,53 @@ loop.standaloneRoomViews = (function(moz
         default:
           console.warn("StandaloneRoomView.shouldRenderRemoteVideo:" +
             " unexpected roomState: ", this.state.roomState);
           return true;
 
       }
     },
 
+    /**
+     * 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
+     */
+    _shouldRenderLocalLoading: function () {
+      return this.state.roomState === ROOM_STATES.MEDIA_WAIT &&
+             !this.state.localSrcVideoObject;
+    },
+
+    /**
+     * 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
+     */
+    _shouldRenderRemoteLoading: function() {
+      return this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS &&
+             !this.state.remoteSrcVideoObject &&
+             !this.state.mediaConnected;
+    },
+
+    /**
+     * Should we render a visual cue to the user (e.g. a spinner) that a remote
+     * screen-share is on its way from the other user?
+     *
+     * @returns {boolean}
+     * @private
+     */
+    _shouldRenderScreenShareLoading: function() {
+      return this.state.receivingScreenShare &&
+             !this.state.screenShareVideoObject;
+    },
+
     render: function() {
       var displayScreenShare = this.state.receivingScreenShare ||
         this.props.screenSharePosterUrl;
 
       var remoteStreamClasses = React.addons.classSet({
         "remote": true,
         "focus-stream": !displayScreenShare
       });
@@ -401,32 +441,35 @@ loop.standaloneRoomViews = (function(moz
           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(sharedViews.MediaView, {displayAvatar: !this.shouldRenderRemoteVideo(), 
                   posterUrl: this.props.remotePosterUrl, 
+                  isLoading: this._shouldRenderRemoteLoading(), 
                   mediaType: "remote", 
                   srcVideoObject: this.state.remoteSrcVideoObject})
               ), 
               React.createElement("div", {className: screenShareStreamClasses}, 
                 React.createElement(sharedViews.MediaView, {displayAvatar: false, 
                   posterUrl: this.props.screenSharePosterUrl, 
+                  isLoading: this._shouldRenderScreenShareLoading(), 
                   mediaType: "screen-share", 
                   srcVideoObject: this.state.screenShareVideoObject})
               ), 
               React.createElement(sharedViews.TextChatView, {
                 dispatcher: this.props.dispatcher, 
                 showAlways: true, 
                 showRoomName: true}), 
               React.createElement("div", {className: "local"}, 
                 React.createElement(sharedViews.MediaView, {displayAvatar: this.state.videoMuted, 
                   posterUrl: this.props.localPosterUrl, 
+                  isLoading: this._shouldRenderLocalLoading(), 
                   mediaType: "local", 
                   srcVideoObject: this.state.localSrcVideoObject})
               )
             ), 
             React.createElement(sharedViews.ConversationToolbar, {
               dispatcher: this.props.dispatcher, 
               video: {enabled: !this.state.videoMuted,
                       visible: this._roomIsActive()}, 
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -324,16 +324,19 @@ loop.standaloneRoomViews = (function(moz
              this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS;
     },
 
     /**
      * Works out if remote video should be rended or not, depending on the
      * room state and other flags.
      *
      * @return {Boolean} True if remote video should be rended.
+     *
+     * XXX Refactor shouldRenderRemoteVideo & shouldRenderLoading to remove
+     *     overlapping cases.
      */
     shouldRenderRemoteVideo: function() {
       switch(this.state.roomState) {
         case ROOM_STATES.HAS_PARTICIPANTS:
           if (this.state.remoteVideoEnabled) {
             return true;
           }
 
@@ -362,16 +365,53 @@ loop.standaloneRoomViews = (function(moz
         default:
           console.warn("StandaloneRoomView.shouldRenderRemoteVideo:" +
             " unexpected roomState: ", this.state.roomState);
           return true;
 
       }
     },
 
+    /**
+     * 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
+     */
+    _shouldRenderLocalLoading: function () {
+      return this.state.roomState === ROOM_STATES.MEDIA_WAIT &&
+             !this.state.localSrcVideoObject;
+    },
+
+    /**
+     * 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
+     */
+    _shouldRenderRemoteLoading: function() {
+      return this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS &&
+             !this.state.remoteSrcVideoObject &&
+             !this.state.mediaConnected;
+    },
+
+    /**
+     * Should we render a visual cue to the user (e.g. a spinner) that a remote
+     * screen-share is on its way from the other user?
+     *
+     * @returns {boolean}
+     * @private
+     */
+    _shouldRenderScreenShareLoading: function() {
+      return this.state.receivingScreenShare &&
+             !this.state.screenShareVideoObject;
+    },
+
     render: function() {
       var displayScreenShare = this.state.receivingScreenShare ||
         this.props.screenSharePosterUrl;
 
       var remoteStreamClasses = React.addons.classSet({
         "remote": true,
         "focus-stream": !displayScreenShare
       });
@@ -401,32 +441,35 @@ loop.standaloneRoomViews = (function(moz
           <div className="media-layout">
             <div className={mediaWrapperClasses}>
               <span className="self-view-hidden-message">
                 {mozL10n.get("self_view_hidden_message")}
               </span>
               <div className={remoteStreamClasses}>
                 <sharedViews.MediaView displayAvatar={!this.shouldRenderRemoteVideo()}
                   posterUrl={this.props.remotePosterUrl}
+                  isLoading={this._shouldRenderRemoteLoading()}
                   mediaType="remote"
                   srcVideoObject={this.state.remoteSrcVideoObject} />
               </div>
               <div className={screenShareStreamClasses}>
                 <sharedViews.MediaView displayAvatar={false}
                   posterUrl={this.props.screenSharePosterUrl}
+                  isLoading={this._shouldRenderScreenShareLoading()}
                   mediaType="screen-share"
                   srcVideoObject={this.state.screenShareVideoObject} />
               </div>
               <sharedViews.TextChatView
                 dispatcher={this.props.dispatcher}
                 showAlways={true}
                 showRoomName={true} />
               <div className="local">
                 <sharedViews.MediaView displayAvatar={this.state.videoMuted}
                   posterUrl={this.props.localPosterUrl}
+                  isLoading={this._shouldRenderLocalLoading()}
                   mediaType="local"
                   srcVideoObject={this.state.localSrcVideoObject} />
               </div>
             </div>
             <sharedViews.ConversationToolbar
               dispatcher={this.props.dispatcher}
               video={{enabled: !this.state.videoMuted,
                       visible: this._roomIsActive()}}
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -523,16 +523,68 @@ describe("loop.roomViews", function () {
           });
 
           view = mountTestComponent();
 
           TestUtils.findRenderedComponentWithType(view,
             loop.shared.views.FeedbackView);
         });
 
+      it("should display loading spinner when localSrcVideoObject is null",
+         function() {
+           activeRoomStore.setStoreState({
+             roomState: ROOM_STATES.MEDIA_WAIT,
+             localSrcVideoObject: null
+           });
+
+           view = mountTestComponent();
+
+           expect(view.getDOMNode().querySelector(".local .loading-stream"))
+               .not.eql(null);
+         });
+
+      it("should not display a loading spinner when local stream available",
+         function() {
+           activeRoomStore.setStoreState({
+             roomState: ROOM_STATES.MEDIA_WAIT,
+             localSrcVideoObject: { fake: "video" }
+           });
+
+           view = mountTestComponent();
+
+           expect(view.getDOMNode().querySelector(".local .loading-stream"))
+               .eql(null);
+         });
+
+      it("should display loading spinner when remote stream is not available",
+         function() {
+           activeRoomStore.setStoreState({
+             roomState: ROOM_STATES.HAS_PARTICIPANTS,
+             remoteSrcVideoObject: null
+           });
+
+           view = mountTestComponent();
+
+           expect(view.getDOMNode().querySelector(".remote .loading-stream"))
+               .not.eql(null);
+         });
+
+      it("should not display a loading spinner when remote stream available",
+         function() {
+           activeRoomStore.setStoreState({
+             roomState: ROOM_STATES.HAS_PARTICIPANTS,
+             remoteSrcVideoObject: { fake: "video" }
+           });
+
+           view = mountTestComponent();
+
+           expect(view.getDOMNode().querySelector(".remote .loading-stream"))
+               .eql(null);
+         });
+
       it("should display an avatar for remote video when the room has participants but video is not enabled",
         function() {
           activeRoomStore.setStoreState({
             roomState: ROOM_STATES.HAS_PARTICIPANTS,
             mediaConnected: true,
             remoteVideoEnabled: false
           });
 
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -987,19 +987,17 @@ describe("loop.OTSdkDriver", function ()
       it("should not dispatch a ReceivingScreenShare action for camera streams",
         function() {
           session.trigger("streamCreated", {stream: fakeStream});
 
           sinon.assert.neverCalledWithMatch(dispatcher.dispatch,
             new sharedActions.ReceivingScreenShare({receiving: true}));
         });
 
-      // XXX See bug 1171933 and the comment in
-      // OtSdkDriver#_handleRemoteScreenShareCreated
-      it.skip("should dispatch a ReceivingScreenShare action for screen" +
+      it("should dispatch a ReceivingScreenShare action for screen" +
         " sharing streams", function() {
           fakeStream.videoType = "screen";
 
           session.trigger("streamCreated", { stream: fakeStream });
 
           // Called twice due to the VideoDimensionsChanged above.
           sinon.assert.called(dispatcher.dispatch);
           sinon.assert.calledWithExactly(dispatcher.dispatch,
--- a/browser/components/loop/test/standalone/standaloneRoomViews_test.js
+++ b/browser/components/loop/test/standalone/standaloneRoomViews_test.js
@@ -235,16 +235,53 @@ describe("loop.standaloneRoomViews", fun
 
           TestUtils.Simulate.click(getJoinButton(view));
 
           sinon.assert.calledOnce(dispatch);
           sinon.assert.calledWithExactly(dispatch, new sharedActions.JoinRoom());
         });
       });
 
+      describe("screenShare", function() {
+        it("should show a loading screen if receivingScreenShare is true " +
+           "but no screenShareVideoObject is present", function() {
+          view.setState({
+            "receivingScreenShare": true,
+            "screenShareVideoObject": null
+          });
+
+          expect(view.getDOMNode().querySelector(".screen .loading-stream"))
+              .not.eql(null);
+        });
+
+        it("should not show loading screen if receivingScreenShare is false " +
+           "and screenShareVideoObject is null", function() {
+             view.setState({
+               "receivingScreenShare": false,
+               "screenShareVideoObject": null
+             });
+
+             expect(view.getDOMNode().querySelector(".screen .loading-stream"))
+                 .eql(null);
+        });
+
+        it("should not show a loading screen if screenShareVideoObject is set",
+           function() {
+             var videoElement = document.createElement("video");
+
+             view.setState({
+               "receivingScreenShare": true,
+               "screenShareVideoObject": videoElement
+             });
+
+             expect(view.getDOMNode().querySelector(".screen .loading-stream"))
+                 .eql(null);
+        });
+      });
+
       describe("Participants", function() {
         var videoElement;
 
         beforeEach(function() {
           videoElement = document.createElement("video");
         });
 
         it("should render local video when video_muted is false", function() {
@@ -261,16 +298,60 @@ describe("loop.standaloneRoomViews", fun
           activeRoomStore.setStoreState({
             roomState: ROOM_STATES.HAS_PARTICIPANTS,
             videoMuted: false
           });
 
           expect(view.getDOMNode().querySelector(".local .avatar")).eql(null);
         });
 
+        it("should render local loading screen when no srcVideoObject",
+           function() {
+             activeRoomStore.setStoreState({
+               roomState: ROOM_STATES.MEDIA_WAIT,
+               remoteSrcVideoObject: null
+             });
+
+             expect(view.getDOMNode().querySelector(".local .loading-stream"))
+                 .not.eql(null);
+        });
+
+        it("should not render local loading screen when srcVideoObject is set",
+           function() {
+             activeRoomStore.setStoreState({
+               roomState: ROOM_STATES.MEDIA_WAIT,
+               localSrcVideoObject: videoElement
+             });
+
+             expect(view.getDOMNode().querySelector(".local .loading-stream"))
+                  .eql(null);
+        });
+
+        it("should not render remote loading screen when srcVideoObject is set",
+           function() {
+             activeRoomStore.setStoreState({
+               roomState: ROOM_STATES.HAS_PARTICIPANTS,
+               remoteSrcVideoObject: videoElement
+             });
+
+             expect(view.getDOMNode().querySelector(".remote .loading-stream"))
+                  .eql(null);
+        });
+
+        it("should render remote video when the room HAS_PARTICIPANTS and" +
+          " remoteVideoEnabled is true", function() {
+          activeRoomStore.setStoreState({
+            roomState: ROOM_STATES.HAS_PARTICIPANTS,
+            remoteSrcVideoObject: videoElement,
+            remoteVideoEnabled: true
+          });
+
+          expect(view.getDOMNode().querySelector(".remote video")).not.eql(null);
+        });
+
         it("should render remote video when the room HAS_PARTICIPANTS and" +
           " remoteVideoEnabled is true", function() {
           activeRoomStore.setStoreState({
             roomState: ROOM_STATES.HAS_PARTICIPANTS,
             remoteSrcVideoObject: videoElement,
             remoteVideoEnabled: true
           });
 
@@ -328,16 +409,28 @@ describe("loop.standaloneRoomViews", fun
             roomState: ROOM_STATES.HAS_PARTICIPANTS,
             remoteSrcVideoObject: videoElement,
             remoteVideoEnabled: false,
             mediaConnected: true
           });
 
           expect(view.getDOMNode().querySelector(".remote .avatar")).not.eql(null);
         });
+
+        it("should render a remote avatar when the room HAS_PARTICIPANTS, " +
+          "remoteSrcVideoObject is false, mediaConnected is true", function() {
+          activeRoomStore.setStoreState({
+            roomState: ROOM_STATES.HAS_PARTICIPANTS,
+            remoteSrcVideoObject: false,
+            remoteVideoEnabled: false,
+            mediaConnected: true
+          });
+
+          expect(view.getDOMNode().querySelector(".remote .avatar")).not.eql(null);
+        });
       });
 
       describe("Leave button", function() {
         function getLeaveButton(elem) {
           return elem.getDOMNode().querySelector(".btn-hangup");
         }
 
         it("should disable the Leave button when the room state is READY",
--- a/browser/components/loop/ui/ui-showcase.css
+++ b/browser/components/loop/ui/ui-showcase.css
@@ -4,16 +4,20 @@
 
 body {
   /* Override the hidden in common.css. Very important otherwise you can't
    * scroll the showcase.
    */
   overflow: visible;
 }
 
+.overflow-hidden {
+  overflow: hidden;
+}
+
 .showcase {
   width: 100%;
   margin: 0 auto;
 }
 
 .showcase-menu,
 .showcase {
   min-width: 350px;
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -174,16 +174,22 @@
   });
 
   var joinedRoomStore = makeActiveRoomStore({
     mediaConnected: false,
     roomState: ROOM_STATES.JOINED,
     remoteVideoEnabled: false
   });
 
+  var loadingRemoteVideoRoomStore = makeActiveRoomStore({
+    mediaConnected: false,
+    roomState: ROOM_STATES.HAS_PARTICIPANTS,
+    remoteSrcVideoObject: false
+  });
+
   var readyRoomStore = makeActiveRoomStore({
     mediaConnected: false,
     roomState: ROOM_STATES.READY
   });
 
   var updatingActiveRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS
   });
@@ -199,31 +205,66 @@
     mediaConnected: true
   });
 
   var updatingSharingRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS,
     receivingScreenShare: true
   });
 
+  var loadingRemoteLoadingScreenStore = makeActiveRoomStore({
+    mediaConnected: false,
+    roomState: ROOM_STATES.HAS_PARTICIPANTS,
+    remoteSrcVideoObject: false
+  });
+  var loadingScreenSharingRoomStore = makeActiveRoomStore({
+    roomState: ROOM_STATES.HAS_PARTICIPANTS
+  });
+
+  /* Set up the stores for pending screen sharing */
+  loadingScreenSharingRoomStore.receivingScreenShare({
+    receiving: true,
+    srcVideoObject: false
+  });
+  loadingRemoteLoadingScreenStore.receivingScreenShare({
+    receiving: true,
+    srcVideoObject: false
+  });
+
   var fullActiveRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.FULL
   });
 
   var failedRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.FAILED
   });
 
   var endedRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.ENDED,
     roomUsed: true
   });
 
+  var invitationRoomStore = new loop.store.RoomStore(dispatcher, {
+    mozLoop: navigator.mozLoop
+  });
+
   var roomStore = new loop.store.RoomStore(dispatcher, {
-    mozLoop: navigator.mozLoop
+    mozLoop: navigator.mozLoop,
+    activeRoomStore: makeActiveRoomStore({
+      roomState: ROOM_STATES.HAS_PARTICIPANTS
+    })
+  });
+
+  var desktopRoomStoreLoading = new loop.store.RoomStore(dispatcher, {
+    mozLoop: navigator.mozLoop,
+    activeRoomStore: makeActiveRoomStore({
+      roomState: ROOM_STATES.HAS_PARTICIPANTS,
+      mediaConnected: false,
+      remoteSrcVideoObject: false
+    })
   });
 
   var desktopLocalFaceMuteActiveRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS,
     videoMuted: true
   });
   var desktopLocalFaceMuteRoomStore = new loop.store.RoomStore(dispatcher, {
     mozLoop: navigator.mozLoop,
@@ -778,25 +819,40 @@
             )
           ), 
 
           React.createElement(Section, {name: "DesktopRoomConversationView"}, 
             React.createElement(FramedExample, {width: 298, height: 254, 
               summary: "Desktop room conversation (invitation)"}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopRoomConversationView, {
-                  roomStore: roomStore, 
+                  roomStore: invitationRoomStore, 
                   dispatcher: dispatcher, 
                   mozLoop: navigator.mozLoop, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   roomState: ROOM_STATES.INIT})
               )
             ), 
 
             React.createElement(FramedExample, {width: 298, height: 254, 
+              summary: "Desktop room conversation (loading)"}, 
+              /* Hide scrollbars here. Rotating loading div overflows and causes
+               scrollbars to appear */
+              React.createElement("div", {className: "fx-embedded overflow-hidden"}, 
+                React.createElement(DesktopRoomConversationView, {
+                  roomStore: desktopRoomStoreLoading, 
+                  dispatcher: dispatcher, 
+                  mozLoop: navigator.mozLoop, 
+                  localPosterUrl: "sample-img/video-screen-local.png", 
+                  remotePosterUrl: "sample-img/video-screen-remote.png", 
+                  roomState: ROOM_STATES.HAS_PARTICIPANTS})
+              )
+            ), 
+
+            React.createElement(FramedExample, {width: 298, height: 254, 
               summary: "Desktop room conversation"}, 
               React.createElement("div", {className: "fx-embedded"}, 
                 React.createElement(DesktopRoomConversationView, {
                   roomStore: roomStore, 
                   dispatcher: dispatcher, 
                   mozLoop: navigator.mozLoop, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   remotePosterUrl: "sample-img/video-screen-remote.png", 
@@ -849,16 +905,29 @@
                   dispatcher: dispatcher, 
                   activeRoomStore: joinedRoomStore, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   isFirefox: true})
               )
             ), 
 
             React.createElement(FramedExample, {width: 644, height: 483, dashed: true, 
+              summary: "Standalone room conversation (loading remote)", 
+              cssClass: "standalone", 
+              onContentsRendered: loadingRemoteVideoRoomStore.forcedUpdate}, 
+              React.createElement("div", {className: "standalone"}, 
+                React.createElement(StandaloneRoomView, {
+                  dispatcher: dispatcher, 
+                  activeRoomStore: loadingRemoteVideoRoomStore, 
+                  localPosterUrl: "sample-img/video-screen-local.png", 
+                  isFirefox: true})
+              )
+            ), 
+
+            React.createElement(FramedExample, {width: 644, height: 483, dashed: true, 
                            cssClass: "standalone", 
                            onContentsRendered: updatingActiveRoomStore.forcedUpdate, 
                            summary: "Standalone room conversation (has-participants, 644x483)"}, 
                 React.createElement("div", {className: "standalone"}, 
                   React.createElement(StandaloneRoomView, {
                     dispatcher: dispatcher, 
                     activeRoomStore: updatingActiveRoomStore, 
                     roomState: ROOM_STATES.HAS_PARTICIPANTS, 
@@ -893,16 +962,54 @@
                   isFirefox: true, 
                   localPosterUrl: "sample-img/video-screen-local.png", 
                   remotePosterUrl: "sample-img/video-screen-remote.png"})
               )
             ), 
 
             React.createElement(FramedExample, {width: 800, height: 660, dashed: true, 
                            cssClass: "standalone", 
+                           onContentsRendered: loadingRemoteLoadingScreenStore.forcedUpdate, 
+              summary: "Standalone room convo (has-participants, loading screen share, loading remote video, 800x660)"}, 
+              /* Hide scrollbars here. Rotating loading div overflows and causes
+               scrollbars to appear */
+               React.createElement("div", {className: "standalone overflow-hidden"}, 
+                  React.createElement(StandaloneRoomView, {
+                    dispatcher: dispatcher, 
+                    activeRoomStore: loadingRemoteLoadingScreenStore, 
+                    roomState: ROOM_STATES.HAS_PARTICIPANTS, 
+                    isFirefox: true, 
+                    localPosterUrl: "sample-img/video-screen-local.png", 
+                    remotePosterUrl: "sample-img/video-screen-remote.png", 
+                    screenSharePosterUrl: "sample-img/video-screen-baz.png"}
+                  )
+                )
+            ), 
+
+            React.createElement(FramedExample, {width: 800, height: 660, dashed: true, 
+                           cssClass: "standalone", 
+                           onContentsRendered: loadingScreenSharingRoomStore.forcedUpdate, 
+              summary: "Standalone room convo (has-participants, loading screen share, 800x660)"}, 
+              /* Hide scrollbars here. Rotating loading div overflows and causes
+               scrollbars to appear */
+               React.createElement("div", {className: "standalone overflow-hidden"}, 
+                  React.createElement(StandaloneRoomView, {
+                    dispatcher: dispatcher, 
+                    activeRoomStore: loadingScreenSharingRoomStore, 
+                    roomState: ROOM_STATES.HAS_PARTICIPANTS, 
+                    isFirefox: true, 
+                    localPosterUrl: "sample-img/video-screen-local.png", 
+                    remotePosterUrl: "sample-img/video-screen-remote.png", 
+                    screenSharePosterUrl: "sample-img/video-screen-baz.png"}
+                  )
+                )
+            ), 
+
+            React.createElement(FramedExample, {width: 800, height: 660, dashed: true, 
+                           cssClass: "standalone", 
                            onContentsRendered: updatingSharingRoomStore.forcedUpdate, 
               summary: "Standalone room convo (has-participants, receivingScreenShare, 800x660)"}, 
                 React.createElement("div", {className: "standalone"}, 
                   React.createElement(StandaloneRoomView, {
                     dispatcher: dispatcher, 
                     activeRoomStore: updatingSharingRoomStore, 
                     roomState: ROOM_STATES.HAS_PARTICIPANTS, 
                     isFirefox: true, 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -174,16 +174,22 @@
   });
 
   var joinedRoomStore = makeActiveRoomStore({
     mediaConnected: false,
     roomState: ROOM_STATES.JOINED,
     remoteVideoEnabled: false
   });
 
+  var loadingRemoteVideoRoomStore = makeActiveRoomStore({
+    mediaConnected: false,
+    roomState: ROOM_STATES.HAS_PARTICIPANTS,
+    remoteSrcVideoObject: false
+  });
+
   var readyRoomStore = makeActiveRoomStore({
     mediaConnected: false,
     roomState: ROOM_STATES.READY
   });
 
   var updatingActiveRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS
   });
@@ -199,31 +205,66 @@
     mediaConnected: true
   });
 
   var updatingSharingRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS,
     receivingScreenShare: true
   });
 
+  var loadingRemoteLoadingScreenStore = makeActiveRoomStore({
+    mediaConnected: false,
+    roomState: ROOM_STATES.HAS_PARTICIPANTS,
+    remoteSrcVideoObject: false
+  });
+  var loadingScreenSharingRoomStore = makeActiveRoomStore({
+    roomState: ROOM_STATES.HAS_PARTICIPANTS
+  });
+
+  /* Set up the stores for pending screen sharing */
+  loadingScreenSharingRoomStore.receivingScreenShare({
+    receiving: true,
+    srcVideoObject: false
+  });
+  loadingRemoteLoadingScreenStore.receivingScreenShare({
+    receiving: true,
+    srcVideoObject: false
+  });
+
   var fullActiveRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.FULL
   });
 
   var failedRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.FAILED
   });
 
   var endedRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.ENDED,
     roomUsed: true
   });
 
+  var invitationRoomStore = new loop.store.RoomStore(dispatcher, {
+    mozLoop: navigator.mozLoop
+  });
+
   var roomStore = new loop.store.RoomStore(dispatcher, {
-    mozLoop: navigator.mozLoop
+    mozLoop: navigator.mozLoop,
+    activeRoomStore: makeActiveRoomStore({
+      roomState: ROOM_STATES.HAS_PARTICIPANTS
+    })
+  });
+
+  var desktopRoomStoreLoading = new loop.store.RoomStore(dispatcher, {
+    mozLoop: navigator.mozLoop,
+    activeRoomStore: makeActiveRoomStore({
+      roomState: ROOM_STATES.HAS_PARTICIPANTS,
+      mediaConnected: false,
+      remoteSrcVideoObject: false
+    })
   });
 
   var desktopLocalFaceMuteActiveRoomStore = makeActiveRoomStore({
     roomState: ROOM_STATES.HAS_PARTICIPANTS,
     videoMuted: true
   });
   var desktopLocalFaceMuteRoomStore = new loop.store.RoomStore(dispatcher, {
     mozLoop: navigator.mozLoop,
@@ -778,25 +819,40 @@
             </Example>
           </Section>
 
           <Section name="DesktopRoomConversationView">
             <FramedExample width={298} height={254}
               summary="Desktop room conversation (invitation)">
               <div className="fx-embedded">
                 <DesktopRoomConversationView
-                  roomStore={roomStore}
+                  roomStore={invitationRoomStore}
                   dispatcher={dispatcher}
                   mozLoop={navigator.mozLoop}
                   localPosterUrl="sample-img/video-screen-local.png"
                   roomState={ROOM_STATES.INIT} />
               </div>
             </FramedExample>
 
             <FramedExample width={298} height={254}
+              summary="Desktop room conversation (loading)">
+              {/* Hide scrollbars here. Rotating loading div overflows and causes
+               scrollbars to appear */}
+              <div className="fx-embedded overflow-hidden">
+                <DesktopRoomConversationView
+                  roomStore={desktopRoomStoreLoading}
+                  dispatcher={dispatcher}
+                  mozLoop={navigator.mozLoop}
+                  localPosterUrl="sample-img/video-screen-local.png"
+                  remotePosterUrl="sample-img/video-screen-remote.png"
+                  roomState={ROOM_STATES.HAS_PARTICIPANTS} />
+              </div>
+            </FramedExample>
+
+            <FramedExample width={298} height={254}
               summary="Desktop room conversation">
               <div className="fx-embedded">
                 <DesktopRoomConversationView
                   roomStore={roomStore}
                   dispatcher={dispatcher}
                   mozLoop={navigator.mozLoop}
                   localPosterUrl="sample-img/video-screen-local.png"
                   remotePosterUrl="sample-img/video-screen-remote.png"
@@ -849,16 +905,29 @@
                   dispatcher={dispatcher}
                   activeRoomStore={joinedRoomStore}
                   localPosterUrl="sample-img/video-screen-local.png"
                   isFirefox={true} />
               </div>
             </FramedExample>
 
             <FramedExample width={644} height={483} dashed={true}
+              summary="Standalone room conversation (loading remote)"
+              cssClass="standalone"
+              onContentsRendered={loadingRemoteVideoRoomStore.forcedUpdate}>
+              <div className="standalone">
+                <StandaloneRoomView
+                  dispatcher={dispatcher}
+                  activeRoomStore={loadingRemoteVideoRoomStore}
+                  localPosterUrl="sample-img/video-screen-local.png"
+                  isFirefox={true} />
+              </div>
+            </FramedExample>
+
+            <FramedExample width={644} height={483} dashed={true}
                            cssClass="standalone"
                            onContentsRendered={updatingActiveRoomStore.forcedUpdate}
                            summary="Standalone room conversation (has-participants, 644x483)">
                 <div className="standalone">
                   <StandaloneRoomView
                     dispatcher={dispatcher}
                     activeRoomStore={updatingActiveRoomStore}
                     roomState={ROOM_STATES.HAS_PARTICIPANTS}
@@ -893,16 +962,54 @@
                   isFirefox={true}
                   localPosterUrl="sample-img/video-screen-local.png"
                   remotePosterUrl="sample-img/video-screen-remote.png" />
               </div>
             </FramedExample>
 
             <FramedExample width={800} height={660} dashed={true}
                            cssClass="standalone"
+                           onContentsRendered={loadingRemoteLoadingScreenStore.forcedUpdate}
+              summary="Standalone room convo (has-participants, loading screen share, loading remote video, 800x660)">
+              {/* Hide scrollbars here. Rotating loading div overflows and causes
+               scrollbars to appear */}
+               <div className="standalone overflow-hidden">
+                  <StandaloneRoomView
+                    dispatcher={dispatcher}
+                    activeRoomStore={loadingRemoteLoadingScreenStore}
+                    roomState={ROOM_STATES.HAS_PARTICIPANTS}
+                    isFirefox={true}
+                    localPosterUrl="sample-img/video-screen-local.png"
+                    remotePosterUrl="sample-img/video-screen-remote.png"
+                    screenSharePosterUrl="sample-img/video-screen-baz.png"
+                  />
+                </div>
+            </FramedExample>
+
+            <FramedExample width={800} height={660} dashed={true}
+                           cssClass="standalone"
+                           onContentsRendered={loadingScreenSharingRoomStore.forcedUpdate}
+              summary="Standalone room convo (has-participants, loading screen share, 800x660)">
+              {/* Hide scrollbars here. Rotating loading div overflows and causes
+               scrollbars to appear */}
+               <div className="standalone overflow-hidden">
+                  <StandaloneRoomView
+                    dispatcher={dispatcher}
+                    activeRoomStore={loadingScreenSharingRoomStore}
+                    roomState={ROOM_STATES.HAS_PARTICIPANTS}
+                    isFirefox={true}
+                    localPosterUrl="sample-img/video-screen-local.png"
+                    remotePosterUrl="sample-img/video-screen-remote.png"
+                    screenSharePosterUrl="sample-img/video-screen-baz.png"
+                  />
+                </div>
+            </FramedExample>
+
+            <FramedExample width={800} height={660} dashed={true}
+                           cssClass="standalone"
                            onContentsRendered={updatingSharingRoomStore.forcedUpdate}
               summary="Standalone room convo (has-participants, receivingScreenShare, 800x660)">
                 <div className="standalone">
                   <StandaloneRoomView
                     dispatcher={dispatcher}
                     activeRoomStore={updatingSharingRoomStore}
                     roomState={ROOM_STATES.HAS_PARTICIPANTS}
                     isFirefox={true}