Bug 1093787 - Insert an additional view for Loop standalone calls to prompt the user to accept the microphone and camera permissions before starting the call. r=nperriault a=lsblakk FIREFOX_BETA_35_BASE
authorMark Banner <standard8@mozilla.com>
Tue, 25 Nov 2014 14:31:43 +0000
changeset 234085 390a34a40ea4e7f4d24b3ed83778e0f408411fcc
parent 234084 7c5884414d694d51f145e3f3579b3f0601c561d2
child 234086 0cf828669d5a0911b6f2b83d501eeef5bdf9905e
push id1
push usersledru@mozilla.com
push dateThu, 04 Dec 2014 17:57:20 +0000
reviewersnperriault, lsblakk
bugs1093787
milestone35.0a2
Bug 1093787 - Insert an additional view for Loop standalone calls to prompt the user to accept the microphone and camera permissions before starting the call. r=nperriault a=lsblakk
browser/components/loop/content/shared/js/models.js
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/standalone/content/l10n/en-US/loop.properties
browser/components/loop/test/shared/models_test.js
browser/components/loop/test/standalone/webapp_test.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -90,16 +90,23 @@ loop.shared.models = (function(l10n) {
      * set-up.
      *
      * @param {String} selectedCallType Call type ("audio" or "audio-video")
      */
     setupOutgoingCall: function(selectedCallType) {
       if (selectedCallType) {
         this.set("selectedCallType", selectedCallType);
       }
+      this.trigger("call:outgoing:get-media-privs");
+    },
+
+    /**
+     * Used to indicate that media privileges have been accepted.
+     */
+    gotMediaPrivs: function() {
       this.trigger("call:outgoing:setup");
     },
 
     /**
      * Starts an outgoing conversation.
      *
      * @param {Object} sessionData The session data received from the
      *                             server for the outgoing call.
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -265,17 +265,90 @@ loop.webapp = (function($, _, OT, mozL10
               mozL10n.get("support_link")
             )
           )
         )
       );
     }
   });
 
+  /**
+   * A view for when conversations are pending, displays any messages
+   * and an option cancel button.
+   */
   var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
+    propTypes: {
+      callState: React.PropTypes.string.isRequired,
+      // If not supplied, the cancel button is not displayed.
+      cancelCallback: React.PropTypes.func
+    },
+
+    render: function() {
+      var cancelButtonClasses = React.addons.classSet({
+        btn: true,
+        "btn-large": true,
+        "btn-cancel": true,
+        hide: !this.props.cancelCallback
+      });
+
+      return (
+        React.DOM.div({className: "container"}, 
+          React.DOM.div({className: "container-box"}, 
+            React.DOM.header({className: "pending-header header-box"}, 
+              ConversationBranding(null)
+            ), 
+
+            React.DOM.div({id: "cameraPreview"}), 
+
+            React.DOM.div({id: "messages"}), 
+
+            React.DOM.p({className: "standalone-btn-label"}, 
+              this.props.callState
+            ), 
+
+            React.DOM.div({className: "btn-pending-cancel-group btn-group"}, 
+              React.DOM.div({className: "flex-padding-1"}), 
+              React.DOM.button({className: cancelButtonClasses, 
+                      onClick: this.props.cancelCallback}, 
+                React.DOM.span({className: "standalone-call-btn-text"}, 
+                  mozL10n.get("initiate_call_cancel_button")
+                )
+              ), 
+              React.DOM.div({className: "flex-padding-1"})
+            )
+          ), 
+          ConversationFooter(null)
+        )
+      );
+    }
+  });
+
+  /**
+   * View displayed whilst the get user media prompt is being displayed. Indicates
+   * to the user to accept the prompt.
+   */
+  var GumPromptConversationView = React.createClass({displayName: 'GumPromptConversationView',
+    render: function() {
+      var callState = mozL10n.get("call_progress_getting_media_description", {
+        clientShortname: mozL10n.get("clientShortname2")
+      });
+      document.title = mozL10n.get("standalone_title_with_status", {
+        clientShortname: mozL10n.get("clientShortname2"),
+        currentStatus: mozL10n.get("call_progress_getting_media_title")
+      });
+
+      return PendingConversationView({callState: callState});
+    }
+  });
+
+  /**
+   * View displayed waiting for a call to be connected. Updates the display
+   * once the websocket shows that the callee is being alerted.
+   */
+  var WaitingConversationView = React.createClass({displayName: 'WaitingConversationView',
     mixins: [sharedMixins.AudioMixin],
 
     getInitialState: function() {
       return {
         callState: "connecting"
       };
     },
 
@@ -297,43 +370,21 @@ loop.webapp = (function($, _, OT, mozL10
 
     _cancelOutgoingCall: function() {
       multiplexGum.reset();
       this.props.websocket.cancel();
     },
 
     render: function() {
       var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
+
       return (
-        React.DOM.div({className: "container"}, 
-          React.DOM.div({className: "container-box"}, 
-            React.DOM.header({className: "pending-header header-box"}, 
-              ConversationBranding(null)
-            ), 
-
-            React.DOM.div({id: "cameraPreview"}), 
-
-            React.DOM.div({id: "messages"}), 
-
-            React.DOM.p({className: "standalone-btn-label"}, 
-              callState
-            ), 
-
-            React.DOM.div({className: "btn-pending-cancel-group btn-group"}, 
-              React.DOM.div({className: "flex-padding-1"}), 
-              React.DOM.button({className: "btn btn-large btn-cancel", 
-                      onClick: this._cancelOutgoingCall}, 
-                React.DOM.span({className: "standalone-call-btn-text"}, 
-                  mozL10n.get("initiate_call_cancel_button")
-                )
-              ), 
-              React.DOM.div({className: "flex-padding-1"})
-            )
-          ), 
-          ConversationFooter(null)
+        PendingConversationView({
+          callState: callState, 
+          cancelCallback: this._cancelOutgoingCall}
         )
       );
     }
   });
 
   var InitiateCallButton = React.createClass({displayName: 'InitiateCallButton',
     mixins: [sharedMixins.DropdownMenuMixin],
 
@@ -449,25 +500,18 @@ loop.webapp = (function($, _, OT, mozL10
      * Takes in a call type parameter "audio" or "audio-video" and returns
      * a function that initiates the call. React click handler requires a function
      * to be called when that event happenes.
      *
      * @param {string} User call type choice "audio" or "audio-video"
      */
     startCall: function(callType) {
       return function() {
-        multiplexGum.getPermsAndCacheMedia({audio:true, video:true},
-          function(localStream) {
-            this.props.conversation.setupOutgoingCall(callType);
-            this.setState({disableCallButton: true});
-          }.bind(this),
-          function(errorCode) {
-            multiplexGum.reset();
-          }.bind(this)
-        );
+        this.props.conversation.setupOutgoingCall(callType);
+        this.setState({disableCallButton: true});
       }.bind(this);
     },
 
     _setConversationTimestamp: function(err, callUrlInfo) {
       if (err) {
         this.props.notifications.errorL10n("unable_retrieve_call_info");
       } else {
         this.setState({
@@ -611,16 +655,17 @@ loop.webapp = (function($, _, OT, mozL10
     getInitialState: function() {
       return {
         callStatus: "start"
       };
     },
 
     componentDidMount: function() {
       this.props.conversation.on("call:outgoing", this.startCall, this);
+      this.props.conversation.on("call:outgoing:get-media-privs", this.getMediaPrivs, this);
       this.props.conversation.on("call:outgoing:setup", this.setupOutgoingCall, this);
       this.props.conversation.on("change:publishedStream", this._checkConnected, this);
       this.props.conversation.on("change:subscribedStream", this._checkConnected, this);
       this.props.conversation.on("session:ended", this._endCall, this);
       this.props.conversation.on("session:peer-hungup", this._onPeerHungup, this);
       this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this);
       this.props.conversation.on("session:connection-error", this._notifyError, this);
     },
@@ -658,18 +703,21 @@ loop.webapp = (function($, _, OT, mozL10
           return (
             FailedConversationView({
               conversation: this.props.conversation, 
               notifications: this.props.notifications, 
               client: this.props.client}
             )
           );
         }
+        case "gumPrompt": {
+          return GumPromptConversationView(null);
+        }
         case "pending": {
-          return PendingConversationView({websocket: this._websocket});
+          return WaitingConversationView({websocket: this._websocket});
         }
         case "connected": {
           return (
             sharedViews.ConversationView({
               initiate: true, 
               sdk: this.props.sdk, 
               model: this.props.conversation, 
               video: {enabled: this.props.conversation.hasVideoStream("outgoing")}}
@@ -756,16 +804,32 @@ loop.webapp = (function($, _, OT, mozL10
             return;
           }
           this.props.conversation.outgoing(sessionData);
         }.bind(this));
       }
     },
 
     /**
+     * Asks the user for the media privileges, handling the result appropriately.
+     */
+    getMediaPrivs: function() {
+      this.setState({callStatus: "gumPrompt"});
+      multiplexGum.getPermsAndCacheMedia({audio:true, video:true},
+        function(localStream) {
+          this.props.conversation.gotMediaPrivs();
+        }.bind(this),
+        function(errorCode) {
+          multiplexGum.reset();
+          this.setState({callStatus: "failure"});
+        }.bind(this)
+      );
+    },
+
+    /**
      * Actually starts the call.
      */
     startCall: function() {
       var loopToken = this.props.conversation.get("loopToken");
       if (!loopToken) {
         this.props.notifications.errorL10n("missing_conversation_info");
         this.setState({callStatus: "failure"});
         return;
@@ -847,16 +911,18 @@ loop.webapp = (function($, _, OT, mozL10
       this.props.notifications.errorL10n("call_timeout_notification_text");
       this.setState({callStatus: "failure"});
     },
 
     /**
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
+      multiplexGum.reset();
+
       if (this.state.callStatus !== "failure") {
         this.setState({callStatus: "end"});
       }
     },
   });
 
   /**
    * Webapp Root View. This is the main, single, view that controls the display
@@ -1031,16 +1097,18 @@ loop.webapp = (function($, _, OT, mozL10
       // urls, the pathname for later ones.
       windowPath: helper.locationData().hash || helper.locationData().pathname
     }));
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
+    GumPromptConversationView: GumPromptConversationView,
+    WaitingConversationView: WaitingConversationView,
     StartConversationView: StartConversationView,
     FailedConversationView: FailedConversationView,
     OutgoingConversationView: OutgoingConversationView,
     EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -265,17 +265,90 @@ loop.webapp = (function($, _, OT, mozL10
               {mozL10n.get("support_link")}
             </a>
           </div>
         </div>
       );
     }
   });
 
+  /**
+   * A view for when conversations are pending, displays any messages
+   * and an option cancel button.
+   */
   var PendingConversationView = React.createClass({
+    propTypes: {
+      callState: React.PropTypes.string.isRequired,
+      // If not supplied, the cancel button is not displayed.
+      cancelCallback: React.PropTypes.func
+    },
+
+    render: function() {
+      var cancelButtonClasses = React.addons.classSet({
+        btn: true,
+        "btn-large": true,
+        "btn-cancel": true,
+        hide: !this.props.cancelCallback
+      });
+
+      return (
+        <div className="container">
+          <div className="container-box">
+            <header className="pending-header header-box">
+              <ConversationBranding />
+            </header>
+
+            <div id="cameraPreview" />
+
+            <div id="messages" />
+
+            <p className="standalone-btn-label">
+              {this.props.callState}
+            </p>
+
+            <div className="btn-pending-cancel-group btn-group">
+              <div className="flex-padding-1" />
+              <button className={cancelButtonClasses}
+                      onClick={this.props.cancelCallback} >
+                <span className="standalone-call-btn-text">
+                  {mozL10n.get("initiate_call_cancel_button")}
+                </span>
+              </button>
+              <div className="flex-padding-1" />
+            </div>
+          </div>
+          <ConversationFooter />
+        </div>
+      );
+    }
+  });
+
+  /**
+   * View displayed whilst the get user media prompt is being displayed. Indicates
+   * to the user to accept the prompt.
+   */
+  var GumPromptConversationView = React.createClass({
+    render: function() {
+      var callState = mozL10n.get("call_progress_getting_media_description", {
+        clientShortname: mozL10n.get("clientShortname2")
+      });
+      document.title = mozL10n.get("standalone_title_with_status", {
+        clientShortname: mozL10n.get("clientShortname2"),
+        currentStatus: mozL10n.get("call_progress_getting_media_title")
+      });
+
+      return <PendingConversationView callState={callState}/>;
+    }
+  });
+
+  /**
+   * View displayed waiting for a call to be connected. Updates the display
+   * once the websocket shows that the callee is being alerted.
+   */
+  var WaitingConversationView = React.createClass({
     mixins: [sharedMixins.AudioMixin],
 
     getInitialState: function() {
       return {
         callState: "connecting"
       };
     },
 
@@ -297,44 +370,22 @@ loop.webapp = (function($, _, OT, mozL10
 
     _cancelOutgoingCall: function() {
       multiplexGum.reset();
       this.props.websocket.cancel();
     },
 
     render: function() {
       var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
+
       return (
-        <div className="container">
-          <div className="container-box">
-            <header className="pending-header header-box">
-              <ConversationBranding />
-            </header>
-
-            <div id="cameraPreview" />
-
-            <div id="messages" />
-
-            <p className="standalone-btn-label">
-              {callState}
-            </p>
-
-            <div className="btn-pending-cancel-group btn-group">
-              <div className="flex-padding-1" />
-              <button className="btn btn-large btn-cancel"
-                      onClick={this._cancelOutgoingCall} >
-                <span className="standalone-call-btn-text">
-                  {mozL10n.get("initiate_call_cancel_button")}
-                </span>
-              </button>
-              <div className="flex-padding-1" />
-            </div>
-          </div>
-          <ConversationFooter />
-        </div>
+        <PendingConversationView
+          callState={callState}
+          cancelCallback={this._cancelOutgoingCall}
+        />
       );
     }
   });
 
   var InitiateCallButton = React.createClass({
     mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
@@ -449,25 +500,18 @@ loop.webapp = (function($, _, OT, mozL10
      * Takes in a call type parameter "audio" or "audio-video" and returns
      * a function that initiates the call. React click handler requires a function
      * to be called when that event happenes.
      *
      * @param {string} User call type choice "audio" or "audio-video"
      */
     startCall: function(callType) {
       return function() {
-        multiplexGum.getPermsAndCacheMedia({audio:true, video:true},
-          function(localStream) {
-            this.props.conversation.setupOutgoingCall(callType);
-            this.setState({disableCallButton: true});
-          }.bind(this),
-          function(errorCode) {
-            multiplexGum.reset();
-          }.bind(this)
-        );
+        this.props.conversation.setupOutgoingCall(callType);
+        this.setState({disableCallButton: true});
       }.bind(this);
     },
 
     _setConversationTimestamp: function(err, callUrlInfo) {
       if (err) {
         this.props.notifications.errorL10n("unable_retrieve_call_info");
       } else {
         this.setState({
@@ -611,16 +655,17 @@ loop.webapp = (function($, _, OT, mozL10
     getInitialState: function() {
       return {
         callStatus: "start"
       };
     },
 
     componentDidMount: function() {
       this.props.conversation.on("call:outgoing", this.startCall, this);
+      this.props.conversation.on("call:outgoing:get-media-privs", this.getMediaPrivs, this);
       this.props.conversation.on("call:outgoing:setup", this.setupOutgoingCall, this);
       this.props.conversation.on("change:publishedStream", this._checkConnected, this);
       this.props.conversation.on("change:subscribedStream", this._checkConnected, this);
       this.props.conversation.on("session:ended", this._endCall, this);
       this.props.conversation.on("session:peer-hungup", this._onPeerHungup, this);
       this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this);
       this.props.conversation.on("session:connection-error", this._notifyError, this);
     },
@@ -658,18 +703,21 @@ loop.webapp = (function($, _, OT, mozL10
           return (
             <FailedConversationView
               conversation={this.props.conversation}
               notifications={this.props.notifications}
               client={this.props.client}
             />
           );
         }
+        case "gumPrompt": {
+          return <GumPromptConversationView />;
+        }
         case "pending": {
-          return <PendingConversationView websocket={this._websocket} />;
+          return <WaitingConversationView websocket={this._websocket} />;
         }
         case "connected": {
           return (
             <sharedViews.ConversationView
               initiate={true}
               sdk={this.props.sdk}
               model={this.props.conversation}
               video={{enabled: this.props.conversation.hasVideoStream("outgoing")}}
@@ -756,16 +804,32 @@ loop.webapp = (function($, _, OT, mozL10
             return;
           }
           this.props.conversation.outgoing(sessionData);
         }.bind(this));
       }
     },
 
     /**
+     * Asks the user for the media privileges, handling the result appropriately.
+     */
+    getMediaPrivs: function() {
+      this.setState({callStatus: "gumPrompt"});
+      multiplexGum.getPermsAndCacheMedia({audio:true, video:true},
+        function(localStream) {
+          this.props.conversation.gotMediaPrivs();
+        }.bind(this),
+        function(errorCode) {
+          multiplexGum.reset();
+          this.setState({callStatus: "failure"});
+        }.bind(this)
+      );
+    },
+
+    /**
      * Actually starts the call.
      */
     startCall: function() {
       var loopToken = this.props.conversation.get("loopToken");
       if (!loopToken) {
         this.props.notifications.errorL10n("missing_conversation_info");
         this.setState({callStatus: "failure"});
         return;
@@ -847,16 +911,18 @@ loop.webapp = (function($, _, OT, mozL10
       this.props.notifications.errorL10n("call_timeout_notification_text");
       this.setState({callStatus: "failure"});
     },
 
     /**
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
+      multiplexGum.reset();
+
       if (this.state.callStatus !== "failure") {
         this.setState({callStatus: "end"});
       }
     },
   });
 
   /**
    * Webapp Root View. This is the main, single, view that controls the display
@@ -1031,16 +1097,18 @@ loop.webapp = (function($, _, OT, mozL10
       // urls, the pathname for later ones.
       windowPath: helper.locationData().hash || helper.locationData().pathname
     }));
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
+    GumPromptConversationView: GumPromptConversationView,
+    WaitingConversationView: WaitingConversationView,
     StartConversationView: StartConversationView,
     FailedConversationView: FailedConversationView,
     OutgoingConversationView: OutgoingConversationView,
     EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
--- a/browser/components/loop/standalone/content/l10n/en-US/loop.properties
+++ b/browser/components/loop/standalone/content/l10n/en-US/loop.properties
@@ -53,16 +53,18 @@ vendorShortname=Mozilla
 
 ## LOCALIZATION NOTE(client_alttext): {{clientShortname}} will be replaced with the
 ## value of the clientShortname2 string above.
 client_alttext={{clientShortname}} logo
 vendor_alttext={{vendorShortname}} logo
 
 ## LOCALIZATION NOTE (call_url_creation_date_label): Example output: (from May 26, 2014)
 call_url_creation_date_label=(from {{call_url_creation_date}})
+call_progress_getting_media_description={{clientShortname}} requires access to your camera and microphone.
+call_progress_getting_media_title=Waiting for media…
 call_progress_connecting_description=Connecting…
 call_progress_ringing_description=Ringing…
 fxos_app_needed=Please install the {{fxosAppName}} app from the Firefox Marketplace.
 
 feedback_call_experience_heading2=How was your conversation?
 feedback_what_makes_you_sad=What makes you sad?
 feedback_thank_you_heading=Thank you for your feedback!
 feedback_category_audio_quality=Audio quality
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -86,22 +86,32 @@ describe("loop.shared.models", function(
 
         it("should respect the default selected call type when none is passed",
           function() {
             conversation.setupOutgoingCall();
 
             expect(conversation.get("selectedCallType")).eql("audio-video");
           });
 
+        it("should trigger a `call:outgoing:get-media-privs` event", function(done) {
+          conversation.once("call:outgoing:get-media-privs", function() {
+            done();
+          });
+
+          conversation.setupOutgoingCall();
+        });
+      });
+
+      describe("#gotMediaPrivs", function() {
         it("should trigger a `call:outgoing:setup` event", function(done) {
           conversation.once("call:outgoing:setup", function() {
             done();
           });
 
-          conversation.setupOutgoingCall();
+          conversation.gotMediaPrivs();
         });
       });
 
       describe("#outgoing", function() {
         beforeEach(function() {
           sandbox.stub(conversation, "endSession");
           sandbox.stub(conversation, "setOutgoingSessionData");
           sandbox.stub(conversation, "setIncomingSessionData");
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -364,16 +364,26 @@ describe("loop.webapp", function() {
             ocView.startCall();
 
             TestUtils.findRenderedComponentWithType(ocView,
               loop.webapp.PendingConversationView);
           });
       });
 
       describe("session:ended", function() {
+        it("should call multiplexGum.reset", function() {
+          var multiplexGum = new standaloneMedia._MultiplexGum();
+          standaloneMedia.setSingleton(multiplexGum);
+          sandbox.stub(standaloneMedia._MultiplexGum.prototype, "reset");
+
+          conversation.trigger("session:ended");
+
+          sinon.assert.calledOnce(multiplexGum.reset);
+        });
+
         it("should display the StartConversationView", function() {
           conversation.trigger("session:ended");
 
           TestUtils.findRenderedComponentWithType(ocView,
             loop.webapp.EndedConversationView);
         });
 
         it("should display the FailedConversationView if callStatus is failure",
@@ -469,84 +479,131 @@ describe("loop.webapp", function() {
 
       describe("#setupOutgoingCall", function() {
         describe("No loop token", function() {
           beforeEach(function() {
             conversation.set("loopToken", "");
           });
 
           it("should display the FailedConversationView", function() {
-            conversation.setupOutgoingCall();
+            ocView.setupOutgoingCall();
 
             TestUtils.findRenderedComponentWithType(ocView,
               loop.webapp.FailedConversationView);
           });
 
           it("should display an error", function() {
-            conversation.setupOutgoingCall();
+            ocView.setupOutgoingCall();
 
             sinon.assert.calledOnce(notifications.errorL10n);
           });
         });
 
         describe("Has loop token", function() {
           beforeEach(function() {
             sandbox.stub(conversation, "outgoing");
           });
 
           it("should call requestCallInfo on the client",
             function() {
-              conversation.setupOutgoingCall("audio-video");
+              conversation.set("selectedCallType", "audio-video");
+              ocView.setupOutgoingCall();
 
               sinon.assert.calledOnce(client.requestCallInfo);
               sinon.assert.calledWith(client.requestCallInfo, "fakeToken",
                                       "audio-video");
             });
 
           describe("requestCallInfo response handling", function() {
             it("should set display the CallUrlExpiredView if the call has expired",
                function() {
                 client.requestCallInfo.callsArgWith(2, {errno: 105});
 
-                conversation.setupOutgoingCall();
+                ocView.setupOutgoingCall();
 
                 TestUtils.findRenderedComponentWithType(ocView,
                   loop.webapp.CallUrlExpiredView);
               });
 
             it("should set display the FailedConversationView on any other error",
                function() {
                 client.requestCallInfo.callsArgWith(2, {errno: 104});
 
-                conversation.setupOutgoingCall();
+                ocView.setupOutgoingCall();
 
                 TestUtils.findRenderedComponentWithType(ocView,
                   loop.webapp.FailedConversationView);
               });
 
             it("should notify the user on any other error", function() {
               client.requestCallInfo.callsArgWith(2, {errno: 104});
 
-              conversation.setupOutgoingCall();
+              ocView.setupOutgoingCall();
 
               sinon.assert.calledOnce(notifications.errorL10n);
             });
 
             it("should call outgoing on the conversation model when details " +
                "are successfully received", function() {
                 client.requestCallInfo.callsArgWith(2, null, fakeSessionData);
 
-                conversation.setupOutgoingCall();
+                ocView.setupOutgoingCall();
 
                 sinon.assert.calledOnce(conversation.outgoing);
                 sinon.assert.calledWithExactly(conversation.outgoing, fakeSessionData);
               });
           });
         });
       });
+
+      describe("getMediaPrivs", function() {
+        var multiplexGum;
+
+        beforeEach(function() {
+          multiplexGum = new standaloneMedia._MultiplexGum();
+          standaloneMedia.setSingleton(multiplexGum);
+          sandbox.stub(standaloneMedia._MultiplexGum.prototype, "reset");
+
+          sandbox.stub(conversation, "gotMediaPrivs");
+        });
+
+        it("should call getPermsAndCacheMedia", function() {
+          conversation.trigger("call:outgoing:get-media-privs");
+
+          sinon.assert.calledOnce(stubGetPermsAndCacheMedia);
+        });
+
+        it("should call gotMediaPrevs on the model when successful", function() {
+          stubGetPermsAndCacheMedia.callsArgWith(1, {});
+
+          conversation.trigger("call:outgoing:get-media-privs");
+
+          sinon.assert.calledOnce(conversation.gotMediaPrivs);
+        });
+
+        it("should call multiplexGum.reset when getPermsAndCacheMedia fails",
+          function() {
+            stubGetPermsAndCacheMedia.callsArgWith(2, "FAKE_ERROR");
+
+            conversation.trigger("call:outgoing:get-media-privs");
+
+            sinon.assert.calledOnce(multiplexGum.reset);
+          });
+
+        it("should set state to `failure` when getPermsAndCacheMedia fails",
+          function() {
+            stubGetPermsAndCacheMedia.callsArgWith(2, "FAKE_ERROR");
+
+            conversation.trigger("call:outgoing:get-media-privs");
+
+            expect(ocView.state.callStatus).eql("failure");
+          });
+      });
+
+
     });
 
     describe("FailedConversationView", function() {
       var view, conversation, client, fakeAudio;
 
       beforeEach(function() {
         sandbox.stub(window, "XMLHttpRequest").returns(fakeAudioXHR);
 
@@ -688,17 +745,17 @@ describe("loop.webapp", function() {
 
       TestUtils.renderIntoDocument(loop.webapp.HomeView());
 
       sinon.assert.calledOnce(multiplexGum.reset);
       sinon.assert.calledWithExactly(multiplexGum.reset);
     });
   });
 
-  describe("PendingConversationView", function() {
+  describe("WaitingConversationView", function() {
     var view, websocket, fakeAudio;
 
     beforeEach(function() {
       websocket = new loop.CallConnectionWebSocket({
         url: "wss://fake/",
         callId: "callId",
         websocketToken: "7b"
       });
@@ -708,17 +765,17 @@ describe("loop.webapp", function() {
         play: sinon.spy(),
         pause: sinon.spy(),
         removeAttribute: sinon.spy()
       };
       sandbox.stub(window, "Audio").returns(fakeAudio);
       sandbox.stub(window, "XMLHttpRequest").returns(fakeAudioXHR);
 
       view = React.addons.TestUtils.renderIntoDocument(
-        loop.webapp.PendingConversationView({
+        loop.webapp.WaitingConversationView({
           websocket: websocket
         })
       );
     });
 
     describe("#componentDidMount", function() {
 
       it("should play a looped connecting sound", function() {
@@ -797,37 +854,18 @@ describe("loop.webapp", function() {
 
         view = React.addons.TestUtils.renderIntoDocument(
             loop.webapp.StartConversationView({
               conversation: conversation,
               notifications: notifications,
               client: standaloneClientStub
             })
         );
-
-        // default to succeeding with a null local media object
-        stubGetPermsAndCacheMedia.callsArgWith(1, {});
       });
 
-      it("should fire multiplexGum.reset when getPermsAndCacheMedia calls" +
-        " back an error",
-        function() {
-          var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
-          var multiplexGum = new standaloneMedia._MultiplexGum();
-          standaloneMedia.setSingleton(multiplexGum);
-          sandbox.stub(standaloneMedia._MultiplexGum.prototype, "reset");
-          stubGetPermsAndCacheMedia.callsArgWith(2, "FAKE_ERROR");
-
-          var button = view.getDOMNode().querySelector(".btn-accept");
-          React.addons.TestUtils.Simulate.click(button);
-
-          sinon.assert.calledOnce(multiplexGum.reset);
-          sinon.assert.calledWithExactly(multiplexGum.reset);
-        });
-
       it("should start the audio-video conversation establishment process",
         function() {
           var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
 
           var button = view.getDOMNode().querySelector(".btn-accept");
           React.addons.TestUtils.Simulate.click(button);
 
           sinon.assert.calledOnce(setupOutgoingCall);
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -23,17 +23,18 @@
   var CallFailedView = loop.conversationViews.CallFailedView;
   var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView  = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView   = loop.webapp.UnsupportedDeviceView;
   var CallUrlExpiredView      = loop.webapp.CallUrlExpiredView;
-  var PendingConversationView = loop.webapp.PendingConversationView;
+  var GumPromptConversationView = loop.webapp.GumPromptConversationView;
+  var WaitingConversationView = loop.webapp.WaitingConversationView;
   var StartConversationView   = loop.webapp.StartConversationView;
   var FailedConversationView  = loop.webapp.FailedConversationView;
   var EndedConversationView   = loop.webapp.EndedConversationView;
   var StandaloneRoomView      = loop.standaloneRoomViews.StandaloneRoomView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var ConversationView = loop.shared.views.ConversationView;
@@ -321,26 +322,34 @@
                 ConversationToolbar({video: {enabled: true}, 
                                      audio: {enabled: false}, 
                                      hangup: noop, 
                                      publishStream: noop})
               )
             )
           ), 
 
-          Section({name: "PendingConversationView"}, 
-            Example({summary: "Pending conversation view (connecting)", dashed: "true"}, 
+          Section({name: "GumPromptConversationView"}, 
+            Example({summary: "Gum Prompt conversation view", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
-                PendingConversationView({websocket: mockWebSocket, 
+                GumPromptConversationView(null)
+              )
+            )
+          ), 
+
+          Section({name: "WaitingConversationView"}, 
+            Example({summary: "Waiting conversation view (connecting)", dashed: "true"}, 
+              React.DOM.div({className: "standalone"}, 
+                WaitingConversationView({websocket: mockWebSocket, 
                                          dispatcher: dispatcher})
               )
             ), 
-            Example({summary: "Pending conversation view (ringing)", dashed: "true"}, 
+            Example({summary: "Waiting conversation view (ringing)", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
-                PendingConversationView({websocket: mockWebSocket, 
+                WaitingConversationView({websocket: mockWebSocket, 
                                          dispatcher: dispatcher, 
                                          callState: "ringing"})
               )
             )
           ), 
 
           Section({name: "PendingConversationView (Desktop)"}, 
             Example({summary: "Connecting", dashed: "true", 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -23,17 +23,18 @@
   var CallFailedView = loop.conversationViews.CallFailedView;
   var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView  = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView   = loop.webapp.UnsupportedDeviceView;
   var CallUrlExpiredView      = loop.webapp.CallUrlExpiredView;
-  var PendingConversationView = loop.webapp.PendingConversationView;
+  var GumPromptConversationView = loop.webapp.GumPromptConversationView;
+  var WaitingConversationView = loop.webapp.WaitingConversationView;
   var StartConversationView   = loop.webapp.StartConversationView;
   var FailedConversationView  = loop.webapp.FailedConversationView;
   var EndedConversationView   = loop.webapp.EndedConversationView;
   var StandaloneRoomView      = loop.standaloneRoomViews.StandaloneRoomView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var ConversationView = loop.shared.views.ConversationView;
@@ -321,26 +322,34 @@
                 <ConversationToolbar video={{enabled: true}}
                                      audio={{enabled: false}}
                                      hangup={noop}
                                      publishStream={noop} />
               </Example>
             </div>
           </Section>
 
-          <Section name="PendingConversationView">
-            <Example summary="Pending conversation view (connecting)" dashed="true">
+          <Section name="GumPromptConversationView">
+            <Example summary="Gum Prompt conversation view" dashed="true">
               <div className="standalone">
-                <PendingConversationView websocket={mockWebSocket}
+                <GumPromptConversationView />
+              </div>
+            </Example>
+          </Section>
+
+          <Section name="WaitingConversationView">
+            <Example summary="Waiting conversation view (connecting)" dashed="true">
+              <div className="standalone">
+                <WaitingConversationView websocket={mockWebSocket}
                                          dispatcher={dispatcher} />
               </div>
             </Example>
-            <Example summary="Pending conversation view (ringing)" dashed="true">
+            <Example summary="Waiting conversation view (ringing)" dashed="true">
               <div className="standalone">
-                <PendingConversationView websocket={mockWebSocket}
+                <WaitingConversationView websocket={mockWebSocket}
                                          dispatcher={dispatcher}
                                          callState="ringing"/>
               </div>
             </Example>
           </Section>
 
           <Section name="PendingConversationView (Desktop)">
             <Example summary="Connecting" dashed="true"