Bug 1074686 - Part 5 Hook up the active room store to the sdk for Loop rooms on desktop to enable audio and video in rooms. r=nperriault
authorMark Banner <standard8@mozilla.com>
Tue, 11 Nov 2014 14:48:56 +0000
changeset 214995 1c1e25f36e74230a0218d88b5707a3e85567b8ed
parent 214994 8aa568ca5527eaa0a8537653b40dd039b5106668
child 214996 00587cff56ee5b229a4f1929891cc5fb0dd614c1
push id9888
push usermbanner@mozilla.com
push dateTue, 11 Nov 2014 14:49:19 +0000
treeherderfx-team@1c1e25f36e74 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnperriault
bugs1074686
milestone36.0a1
Bug 1074686 - Part 5 Hook up the active room store to the sdk for Loop rooms on desktop to enable audio and video in rooms. r=nperriault
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/roomViews.js
browser/components/loop/content/js/roomViews.jsx
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/standalone/content/index.html
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/desktop-local/roomViews_test.js
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/shared/otSdkDriver_test.js
browser/components/loop/test/shared/roomStore_test.js
browser/components/loop/test/standalone/index.html
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/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -581,17 +581,18 @@ loop.conversation = (function(mozL10n) {
           return (OutgoingConversationView({
             store: this.props.conversationStore, 
             dispatcher: this.props.dispatcher}
           ));
         }
         case "room": {
           return (DesktopRoomConversationView({
             dispatcher: this.props.dispatcher, 
-            roomStore: this.props.roomStore}
+            roomStore: this.props.roomStore, 
+            dispatcher: this.props.dispatcher}
           ));
         }
         case "failed": {
           return (GenericFailureView({
             cancelCall: this.closeWindow}
           ));
         }
         default: {
@@ -637,17 +638,18 @@ loop.conversation = (function(mozL10n) {
     });
     var conversationStore = new loop.store.ConversationStore({}, {
       client: client,
       dispatcher: dispatcher,
       sdkDriver: sdkDriver
     });
     var activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      mozLoop: navigator.mozLoop
+      mozLoop: navigator.mozLoop,
+      sdkDriver: sdkDriver
     });
     var roomStore = new loop.store.RoomStore({
       dispatcher: dispatcher,
       mozLoop: navigator.mozLoop,
       activeRoomStore: activeRoomStore
     });
 
     // XXX Old class creation for the incoming conversation view, whilst
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -582,16 +582,17 @@ loop.conversation = (function(mozL10n) {
             store={this.props.conversationStore}
             dispatcher={this.props.dispatcher}
           />);
         }
         case "room": {
           return (<DesktopRoomConversationView
             dispatcher={this.props.dispatcher}
             roomStore={this.props.roomStore}
+            dispatcher={this.props.dispatcher}
           />);
         }
         case "failed": {
           return (<GenericFailureView
             cancelCall={this.closeWindow}
           />);
         }
         default: {
@@ -637,17 +638,18 @@ loop.conversation = (function(mozL10n) {
     });
     var conversationStore = new loop.store.ConversationStore({}, {
       client: client,
       dispatcher: dispatcher,
       sdkDriver: sdkDriver
     });
     var activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      mozLoop: navigator.mozLoop
+      mozLoop: navigator.mozLoop,
+      sdkDriver: sdkDriver
     });
     var roomStore = new loop.store.RoomStore({
       dispatcher: dispatcher,
       mozLoop: navigator.mozLoop,
       activeRoomStore: activeRoomStore
     });
 
     // XXX Old class creation for the incoming conversation view, whilst
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -6,16 +6,17 @@
 
 /* jshint newcap:false */
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.roomViews = (function(mozL10n) {
   "use strict";
 
+  var sharedActions = loop.shared.actions;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var sharedViews = loop.shared.views;
 
   function noop() {}
 
   /**
    * ActiveRoomStore mixin.
    * @type {Object}
@@ -32,17 +33,22 @@ loop.roomViews = (function(mozL10n) {
                     this._onActiveRoomStateChanged);
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.roomStore);
     },
 
     _onActiveRoomStateChanged: function() {
-      this.setState(this.props.roomStore.getStoreState("activeRoom"));
+      // Only update the state if we're mounted, to avoid the problem where
+      // stopListening doesn't nuke the active listeners during a event
+      // processing.
+      if (this.isMounted()) {
+        this.setState(this.props.roomStore.getStoreState("activeRoom"));
+      }
     },
 
     getInitialState: function() {
       var storeState = this.props.roomStore.getStoreState("activeRoom");
       return _.extend(storeState, {
         // Used by the UI showcase.
         roomState: this.props.roomState || storeState.roomState
       });
@@ -99,47 +105,121 @@ loop.roomViews = (function(mozL10n) {
 
   /**
    * Desktop room conversation view.
    */
   var DesktopRoomConversationView = React.createClass({displayName: 'DesktopRoomConversationView',
     mixins: [ActiveRoomStoreMixin, loop.shared.mixins.DocumentTitleMixin],
 
     propTypes: {
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
-      video: React.PropTypes.object,
-      audio: React.PropTypes.object
-    },
-
-    getDefaultProps: function() {
-      return {
-        video: {enabled: true, visible: true},
-        audio: {enabled: true, visible: true}
-      };
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
     },
 
     _renderInvitationOverlay: function() {
       if (this.state.roomState !== ROOM_STATES.HAS_PARTICIPANTS) {
         return DesktopRoomInvitationView({
           roomStore: this.props.roomStore, 
           dispatcher: this.props.dispatcher}
         );
       }
       return null;
     },
 
+    componentDidMount: function() {
+      /**
+       * OT inserts inline styles into the markup. Using a listener for
+       * resize events helps us trigger a full width/height on the element
+       * so that they update to the correct dimensions.
+       * XXX: this should be factored as a mixin.
+       */
+      window.addEventListener('orientationchange', this.updateVideoContainer);
+      window.addEventListener('resize', this.updateVideoContainer);
+
+      // The SDK needs to know about the configuration and the elements to use
+      // for display. So the best way seems to pass the information here - ideally
+      // the sdk wouldn't need to know this, but we can't change that.
+      this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        publisherConfig: this._getPublisherConfig(),
+        getLocalElementFunc: this._getElement.bind(this, ".local"),
+        getRemoteElementFunc: this._getElement.bind(this, ".remote")
+      }));
+    },
+
+    _getPublisherConfig: function() {
+      // height set to 100%" to fix video layout on Google Chrome
+      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
+      return {
+        insertMode: "append",
+        width: "100%",
+        height: "100%",
+        publishVideo: !this.state.videoMuted,
+        style: {
+          audioLevelDisplayMode: "off",
+          bugDisplayMode: "off",
+          buttonDisplayMode: "off",
+          nameDisplayMode: "off",
+          videoDisabledDisplayMode: "off"
+        }
+      };
+    },
+
+    /**
+     * Used to update the video container whenever the orientation or size of the
+     * display area changes.
+     */
+    updateVideoContainer: function() {
+      var localStreamParent = this._getElement('.local .OT_publisher');
+      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
+    },
+
+    /**
+     * Returns either the required DOMNode
+     *
+     * @param {String} className The name of the class to get the element for.
+     */
+    _getElement: function(className) {
+      return this.getDOMNode().querySelector(className);
+    },
+
+    /**
+     * Closes the window if the cancel button is pressed in the generic failure view.
+     */
+    closeWindow: function() {
+      window.close();
+    },
+
+    /**
+     * Used to control publishing a stream - i.e. to mute a stream
+     *
+     * @param {String} type The type of stream, e.g. "audio" or "video".
+     * @param {Boolean} enabled True to enable the stream, false otherwise.
+     */
+    publishStream: function(type, enabled) {
+      this.props.dispatcher.dispatch(
+        new sharedActions.SetMute({
+          type: type,
+          enabled: enabled
+        }));
+    },
+
     render: function() {
       if (this.state.roomName) {
         this.setTitle(this.state.roomName);
       }
 
       var localStreamClasses = React.addons.classSet({
         local: true,
         "local-stream": true,
-        "local-stream-audio": !this.props.video.enabled
+        "local-stream-audio": !this.state.videoMuted
       });
 
       switch(this.state.roomState) {
         case ROOM_STATES.FAILED: {
           return loop.conversation.GenericFailureView({
             cancelCall: this.closeWindow}
           );
         }
@@ -151,19 +231,19 @@ loop.roomViews = (function(mozL10n) {
                 React.DOM.div({className: "conversation room-conversation"}, 
                   React.DOM.div({className: "media nested"}, 
                     React.DOM.div({className: "video_wrapper remote_wrapper"}, 
                       React.DOM.div({className: "video_inner remote"})
                     ), 
                     React.DOM.div({className: localStreamClasses})
                   ), 
                   sharedViews.ConversationToolbar({
-                    video: this.props.video, 
-                    audio: this.props.audio, 
-                    publishStream: noop, 
+                    video: {enabled: !this.state.videoMuted, visible: true}, 
+                    audio: {enabled: !this.state.audioMuted, visible: true}, 
+                    publishStream: this.publishStream, 
                     hangup: noop})
                 )
               )
             )
           );
         }
       }
     }
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -6,16 +6,17 @@
 
 /* jshint newcap:false */
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.roomViews = (function(mozL10n) {
   "use strict";
 
+  var sharedActions = loop.shared.actions;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var sharedViews = loop.shared.views;
 
   function noop() {}
 
   /**
    * ActiveRoomStore mixin.
    * @type {Object}
@@ -32,17 +33,22 @@ loop.roomViews = (function(mozL10n) {
                     this._onActiveRoomStateChanged);
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.roomStore);
     },
 
     _onActiveRoomStateChanged: function() {
-      this.setState(this.props.roomStore.getStoreState("activeRoom"));
+      // Only update the state if we're mounted, to avoid the problem where
+      // stopListening doesn't nuke the active listeners during a event
+      // processing.
+      if (this.isMounted()) {
+        this.setState(this.props.roomStore.getStoreState("activeRoom"));
+      }
     },
 
     getInitialState: function() {
       var storeState = this.props.roomStore.getStoreState("activeRoom");
       return _.extend(storeState, {
         // Used by the UI showcase.
         roomState: this.props.roomState || storeState.roomState
       });
@@ -99,47 +105,121 @@ loop.roomViews = (function(mozL10n) {
 
   /**
    * Desktop room conversation view.
    */
   var DesktopRoomConversationView = React.createClass({
     mixins: [ActiveRoomStoreMixin, loop.shared.mixins.DocumentTitleMixin],
 
     propTypes: {
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
-      video: React.PropTypes.object,
-      audio: React.PropTypes.object
-    },
-
-    getDefaultProps: function() {
-      return {
-        video: {enabled: true, visible: true},
-        audio: {enabled: true, visible: true}
-      };
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
     },
 
     _renderInvitationOverlay: function() {
       if (this.state.roomState !== ROOM_STATES.HAS_PARTICIPANTS) {
         return <DesktopRoomInvitationView
           roomStore={this.props.roomStore}
           dispatcher={this.props.dispatcher}
         />;
       }
       return null;
     },
 
+    componentDidMount: function() {
+      /**
+       * OT inserts inline styles into the markup. Using a listener for
+       * resize events helps us trigger a full width/height on the element
+       * so that they update to the correct dimensions.
+       * XXX: this should be factored as a mixin.
+       */
+      window.addEventListener('orientationchange', this.updateVideoContainer);
+      window.addEventListener('resize', this.updateVideoContainer);
+
+      // The SDK needs to know about the configuration and the elements to use
+      // for display. So the best way seems to pass the information here - ideally
+      // the sdk wouldn't need to know this, but we can't change that.
+      this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        publisherConfig: this._getPublisherConfig(),
+        getLocalElementFunc: this._getElement.bind(this, ".local"),
+        getRemoteElementFunc: this._getElement.bind(this, ".remote")
+      }));
+    },
+
+    _getPublisherConfig: function() {
+      // height set to 100%" to fix video layout on Google Chrome
+      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
+      return {
+        insertMode: "append",
+        width: "100%",
+        height: "100%",
+        publishVideo: !this.state.videoMuted,
+        style: {
+          audioLevelDisplayMode: "off",
+          bugDisplayMode: "off",
+          buttonDisplayMode: "off",
+          nameDisplayMode: "off",
+          videoDisabledDisplayMode: "off"
+        }
+      };
+    },
+
+    /**
+     * Used to update the video container whenever the orientation or size of the
+     * display area changes.
+     */
+    updateVideoContainer: function() {
+      var localStreamParent = this._getElement('.local .OT_publisher');
+      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
+    },
+
+    /**
+     * Returns either the required DOMNode
+     *
+     * @param {String} className The name of the class to get the element for.
+     */
+    _getElement: function(className) {
+      return this.getDOMNode().querySelector(className);
+    },
+
+    /**
+     * Closes the window if the cancel button is pressed in the generic failure view.
+     */
+    closeWindow: function() {
+      window.close();
+    },
+
+    /**
+     * Used to control publishing a stream - i.e. to mute a stream
+     *
+     * @param {String} type The type of stream, e.g. "audio" or "video".
+     * @param {Boolean} enabled True to enable the stream, false otherwise.
+     */
+    publishStream: function(type, enabled) {
+      this.props.dispatcher.dispatch(
+        new sharedActions.SetMute({
+          type: type,
+          enabled: enabled
+        }));
+    },
+
     render: function() {
       if (this.state.roomName) {
         this.setTitle(this.state.roomName);
       }
 
       var localStreamClasses = React.addons.classSet({
         local: true,
         "local-stream": true,
-        "local-stream-audio": !this.props.video.enabled
+        "local-stream-audio": !this.state.videoMuted
       });
 
       switch(this.state.roomState) {
         case ROOM_STATES.FAILED: {
           return <loop.conversation.GenericFailureView
             cancelCall={this.closeWindow}
           />;
         }
@@ -151,19 +231,19 @@ loop.roomViews = (function(mozL10n) {
                 <div className="conversation room-conversation">
                   <div className="media nested">
                     <div className="video_wrapper remote_wrapper">
                       <div className="video_inner remote"></div>
                     </div>
                     <div className={localStreamClasses}></div>
                   </div>
                   <sharedViews.ConversationToolbar
-                    video={this.props.video}
-                    audio={this.props.audio}
-                    publishStream={noop}
+                    video={{enabled: !this.state.videoMuted, visible: true}}
+                    audio={{enabled: !this.state.audioMuted, visible: true}}
+                    publishStream={this.publishStream}
                     hangup={noop} />
                 </div>
               </div>
             </div>
           );
         }
       }
     }
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -131,16 +131,28 @@ loop.shared.actions = (function() {
      * Used for notifying of connection failures.
      */
     ConnectionFailure: Action.define("connectionFailure", {
       // A string relating to the reason the connection failed.
       reason: String
     }),
 
     /**
+     * Used to notify that the sdk session is now connected to the servers.
+     */
+    ConnectedToSdkServers: Action.define("connectedToSdkServers", {
+    }),
+
+    /**
+     * Used to notify that a remote peer has connected to the room.
+     */
+    RemotePeerConnected: Action.define("remotePeerConnected", {
+    }),
+
+    /**
      * Used by the ongoing views to notify stores about the elements
      * required for the sdk.
      */
     SetupStreamElements: Action.define("setupStreamElements", {
       // The configuration for the publisher/subscribe options
       publisherConfig: Object,
       // The local stream element
       getLocalElementFunc: Function,
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -15,20 +15,22 @@ loop.store.ActiveRoomStore = (function()
     // The initial state of the room
     INIT: "room-init",
     // The store is gathering the room data
     GATHER: "room-gather",
     // The store has got the room data
     READY: "room-ready",
     // The room is known to be joined on the loop-server
     JOINED: "room-joined",
+    // The room is connected to the sdk server.
+    SESSION_CONNECTED: "room-session-connected",
+    // There are participants in the room.
+    HAS_PARTICIPANTS: "room-has-participants",
     // There was an issue with the room
-    FAILED: "room-failed",
-    // XXX to be implemented in bug 1074686/1074702
-    HAS_PARTICIPANTS: "room-has-participants"
+    FAILED: "room-failed"
   };
 
   /**
    * Store for things that are local to this instance (in this profile, on
    * this machine) of this roomRoom store, in addition to a mirror of some
    * remote-state.
    *
    * @extends {Backbone.Events}
@@ -46,16 +48,21 @@ loop.store.ActiveRoomStore = (function()
     }
     this._dispatcher = options.dispatcher;
 
     if (!options.mozLoop) {
       throw new Error("Missing option mozLoop");
     }
     this._mozLoop = options.mozLoop;
 
+    if (!options.sdkDriver) {
+      throw new Error("Missing option sdkDriver");
+    }
+    this._sdkDriver = options.sdkDriver;
+
     // XXX Further actions are registered in setupWindowData and
     // fetchServerData when we know what window type this is. At some stage,
     // we might want to consider store mixins or some alternative which
     // means the stores would only be created when we want them.
     this._dispatcher.register(this, [
       "setupWindowData",
       "fetchServerData"
     ]);
@@ -68,17 +75,19 @@ loop.store.ActiveRoomStore = (function()
      *      for the main data. Additional properties below.
      *
      * @property {ROOM_STATES} roomState - the state of the room.
      * @property {Error=} error - if the room is an error state, this will be
      *                            set to an Error object reflecting the problem;
      *                            otherwise it will be unset.
      */
     this._storeState = {
-      roomState: ROOM_STATES.INIT
+      roomState: ROOM_STATES.INIT,
+      audioMuted: false,
+      videoMuted: false
     };
   }
 
   ActiveRoomStore.prototype = _.extend({
     /**
      * The time factor to adjust the expires time to ensure that we send a refresh
      * before the expiry. Currently set as 90%.
      */
@@ -116,16 +125,21 @@ loop.store.ActiveRoomStore = (function()
      * in.
      */
     _registerActions: function() {
       this._dispatcher.register(this, [
         "roomFailure",
         "updateRoomInfo",
         "joinRoom",
         "joinedRoom",
+        "connectedToSdkServers",
+        "connectionFailure",
+        "setMute",
+        "remotePeerDisconnected",
+        "remotePeerConnected",
         "windowUnload",
         "leaveRoom"
       ]);
     },
 
     /**
      * Execute setupWindowData event action from the dispatcher. This gets
      * the room data from the mozLoop api, and dispatches an UpdateRoomInfo event.
@@ -240,16 +254,67 @@ loop.store.ActiveRoomStore = (function()
       this.setStoreState({
         apiKey: actionData.apiKey,
         sessionToken: actionData.sessionToken,
         sessionId: actionData.sessionId,
         roomState: ROOM_STATES.JOINED
       });
 
       this._setRefreshTimeout(actionData.expires);
+      this._sdkDriver.connectSession(actionData);
+    },
+
+    /**
+     * Handles recording when the sdk has connected to the servers.
+     */
+    connectedToSdkServers: function() {
+      this.setStoreState({
+        roomState: ROOM_STATES.SESSION_CONNECTED
+      });
+    },
+
+    /**
+     * Handles disconnection of this local client from the sdk servers.
+     */
+    connectionFailure: function() {
+      // Treat all reasons as something failed. In theory, clientDisconnected
+      // could be a success case, but there's no way we should be intentionally
+      // sending that and still have the window open.
+      this._leaveRoom(ROOM_STATES.FAILED);
+    },
+
+    /**
+     * Records the mute state for the stream.
+     *
+     * @param {sharedActions.setMute} actionData The mute state for the stream type.
+     */
+    setMute: function(actionData) {
+      var muteState = {};
+      muteState[actionData.type + "Muted"] = !actionData.enabled;
+      this.setStoreState(muteState);
+    },
+
+    /**
+     * Handles recording when a remote peer has connected to the servers.
+     */
+    remotePeerConnected: function() {
+      this.setStoreState({
+        roomState: ROOM_STATES.HAS_PARTICIPANTS
+      });
+    },
+
+    /**
+     * Handles a remote peer disconnecting from the session.
+     */
+    remotePeerDisconnected: function() {
+      // As we only support two users at the moment, we just set this
+      // back to joined.
+      this.setStoreState({
+        roomState: ROOM_STATES.SESSION_CONNECTED
+      });
     },
 
     /**
      * Handles the window being unloaded. Ensures the room is left.
      */
     windowUnload: function() {
       this._leaveRoom();
     },
@@ -287,32 +352,37 @@ loop.store.ActiveRoomStore = (function()
 
           this._setRefreshTimeout(responseData.expires);
         }.bind(this));
     },
 
     /**
      * Handles leaving a room. Clears any membership timeouts, then
      * signals to the server the leave of the room.
+     *
+     * @param {ROOM_STATES} nextState Optional; the next state to switch to.
+     *                                Switches to READY if undefined.
      */
-    _leaveRoom: function() {
-      if (this._storeState.roomState !== ROOM_STATES.JOINED) {
-        return;
-      }
+    _leaveRoom: function(nextState) {
+      this._sdkDriver.disconnectSession();
 
       if (this._timeout) {
         clearTimeout(this._timeout);
         delete this._timeout;
       }
 
-      this._mozLoop.rooms.leave(this._storeState.roomToken,
-        this._storeState.sessionToken);
+      if (this._storeState.roomState === ROOM_STATES.JOINED ||
+          this._storeState.roomState === ROOM_STATES.SESSION_CONNECTED ||
+          this._storeState.roomState === ROOM_STATES.HAS_PARTICIPANTS) {
+        this._mozLoop.rooms.leave(this._storeState.roomToken,
+          this._storeState.sessionToken);
+      }
 
       this.setStoreState({
-        roomState: ROOM_STATES.READY
+        roomState: nextState ? nextState : ROOM_STATES.READY
       });
     }
 
   }, Backbone.Events);
 
   return ActiveRoomStore;
 
 })();
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -74,16 +74,17 @@ loop.OTSdkDriver = (function() {
      * - apiKey: The OT API key
      * - sessionToken: The token for the OT session
      *
      * @param {Object} sessionData The session data for setting up the OT session.
      */
     connectSession: function(sessionData) {
       this.session = this.sdk.initSession(sessionData.sessionId);
 
+      this.session.on("connectionCreated", this._onConnectionCreated.bind(this));
       this.session.on("streamCreated", this._onRemoteStreamCreated.bind(this));
       this.session.on("connectionDestroyed",
         this._onConnectionDestroyed.bind(this));
       this.session.on("sessionDisconnected",
         this._onSessionDisconnected.bind(this));
 
       // This starts the actual session connection.
       this.session.connect(sessionData.apiKey, sessionData.sessionToken,
@@ -125,16 +126,17 @@ loop.OTSdkDriver = (function() {
       if (error) {
         console.error("Failed to complete connection", error);
         this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
           reason: "couldNotConnect"
         }));
         return;
       }
 
+      this.dispatcher.dispatch(new sharedActions.ConnectedToSdkServers());
       this._sessionConnected = true;
       this._maybePublishLocalStream();
     },
 
     /**
      * Handles the connection event for a peer's connection being dropped.
      *
      * @param {SessionDisconnectEvent} event The event details
@@ -157,16 +159,24 @@ loop.OTSdkDriver = (function() {
       // We only need to worry about the network disconnected reason here.
       if (event.reason === "networkDisconnected") {
         this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
           reason: "networkDisconnected"
         }));
       }
     },
 
+    _onConnectionCreated: function(event) {
+      if (this.session.connection.id === event.connection.id) {
+        return;
+      }
+
+      this.dispatcher.dispatch(new sharedActions.RemotePeerConnected());
+    },
+
     /**
      * Handles the event when the remote stream is created.
      *
      * @param {StreamEvent} event The event details:
      * https://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html
      */
     _onRemoteStreamCreated: function(event) {
       this.session.subscribe(event.stream,
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -91,16 +91,17 @@
     <script type="text/javascript" src="shared/js/models.js"></script>
     <script type="text/javascript" src="shared/js/mixins.js"></script>
     <script type="text/javascript" src="shared/js/views.js"></script>
     <script type="text/javascript" src="shared/js/feedbackApiClient.js"></script>
     <script type="text/javascript" src="shared/js/actions.js"></script>
     <script type="text/javascript" src="shared/js/validate.js"></script>
     <script type="text/javascript" src="shared/js/dispatcher.js"></script>
     <script type="text/javascript" src="shared/js/websocket.js"></script>
+    <script type="text/javascript" src="shared/js/otSdkDriver.js"></script>
     <script type="text/javascript" src="shared/js/activeRoomStore.js"></script>
     <script type="text/javascript" src="js/standaloneAppStore.js"></script>
     <script type="text/javascript" src="js/standaloneClient.js"></script>
     <script type="text/javascript" src="js/standaloneMozLoop.js"></script>
     <script type="text/javascript" src="js/standaloneRoomViews.js"></script>
     <script type="text/javascript" src="js/webapp.js"></script>
 
     <script>
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -982,26 +982,31 @@ loop.webapp = (function($, _, OT, mozL10
         url: document.location.origin
       });
 
     // New flux items.
     var dispatcher = new loop.Dispatcher();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
+    var sdkDriver = new loop.OTSdkDriver({
+      dispatcher: dispatcher,
+      sdk: OT
+    });
 
     var standaloneAppStore = new loop.store.StandaloneAppStore({
       conversation: conversation,
       dispatcher: dispatcher,
       helper: helper,
       sdk: OT
     });
     var activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      mozLoop: standaloneMozLoop
+      mozLoop: standaloneMozLoop,
+      sdkDriver: sdkDriver
     });
 
     window.addEventListener("unload", function() {
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(WebappRootView({
       client: client, 
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -982,26 +982,31 @@ loop.webapp = (function($, _, OT, mozL10
         url: document.location.origin
       });
 
     // New flux items.
     var dispatcher = new loop.Dispatcher();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
+    var sdkDriver = new loop.OTSdkDriver({
+      dispatcher: dispatcher,
+      sdk: OT
+    });
 
     var standaloneAppStore = new loop.store.StandaloneAppStore({
       conversation: conversation,
       dispatcher: dispatcher,
       helper: helper,
       sdk: OT
     });
     var activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      mozLoop: standaloneMozLoop
+      mozLoop: standaloneMozLoop,
+      sdkDriver: sdkDriver
     });
 
     window.addEventListener("unload", function() {
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(<WebappRootView
       client={client}
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -138,17 +138,18 @@ describe("loop.conversation", function()
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversation.AppControllerView({
           client: client,
           conversation: conversation,
           roomStore: roomStore,
           sdk: {},
           conversationStore: conversationStore,
-          conversationAppStore: conversationAppStore
+          conversationAppStore: conversationAppStore,
+          dispatcher: dispatcher
         }));
     }
 
     beforeEach(function() {
       oldTitle = document.title;
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
         sdk: {}
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -27,17 +27,18 @@ describe("loop.roomViews", function () {
     // XXX These stubs should be hoisted in a common file
     // Bug 1040968
     sandbox.stub(document.mozL10n, "get", function(x) {
       return x;
     });
 
     activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      mozLoop: {}
+      mozLoop: {},
+      sdkDriver: {}
     });
     roomStore = new loop.store.RoomStore({
       dispatcher: dispatcher,
       mozLoop: {},
       activeRoomStore: activeRoomStore
     });
   });
 
@@ -57,46 +58,117 @@ describe("loop.roomViews", function () {
       });
 
       var testView = TestUtils.renderIntoDocument(TestView({
         roomStore: activeRoomStore
       }));
 
       expect(testView.state).eql({
         roomState: ROOM_STATES.INIT,
+        audioMuted: false,
+        videoMuted: false,
         foo: "bar"
       });
     });
 
     it("should listen to store changes", function() {
       var TestView = React.createClass({
         mixins: [loop.roomViews.ActiveRoomStoreMixin],
         render: function() { return React.DOM.div(); }
       });
       var testView = TestUtils.renderIntoDocument(TestView({
         roomStore: activeRoomStore
       }));
 
       activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
 
-      expect(testView.state).eql({roomState: ROOM_STATES.READY});
+      expect(testView.state.roomState).eql(ROOM_STATES.READY);
     });
   });
 
   describe("DesktopRoomConversationView", function() {
     var view;
 
+    beforeEach(function() {
+      sandbox.stub(dispatcher, "dispatch");
+    });
+
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         new loop.roomViews.DesktopRoomConversationView({
           dispatcher: dispatcher,
           roomStore: roomStore
         }));
     }
 
+    it("should dispatch a setupStreamElements action when the view is created",
+      function() {
+        view = mountTestComponent();
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setupStreamElements"));
+    });
+
+    it("should dispatch a setMute action when the audio mute button is pressed",
+      function() {
+        view = mountTestComponent();
+
+        view.setState({audioMuted: true});
+
+        var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
+
+        React.addons.TestUtils.Simulate.click(muteBtn);
+
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setMute"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("enabled", true));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("type", "audio"));
+      });
+
+    it("should dispatch a setMute action when the video mute button is pressed",
+      function() {
+        view = mountTestComponent();
+
+        view.setState({videoMuted: false});
+
+        var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
+
+        React.addons.TestUtils.Simulate.click(muteBtn);
+
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setMute"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("enabled", false));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("type", "video"));
+      });
+
+    it("should set the mute button as mute off", function() {
+      view = mountTestComponent();
+
+      view.setState({videoMuted: false});
+
+      var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
+
+      expect(muteBtn.classList.contains("muted")).eql(false);
+    });
+
+    it("should set the mute button as mute on", function() {
+      view = mountTestComponent();
+
+      view.setState({audioMuted: true});
+
+      var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
+
+      expect(muteBtn.classList.contains("muted")).eql(true);
+    });
+
     describe("#render", function() {
       it("should set document.title to store.serverData.roomName", function() {
         mountTestComponent();
 
         activeRoomStore.setStoreState({roomName: "fakeName"});
 
         expect(fakeWindow.document.title).to.equal("fakeName");
       });
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -2,17 +2,17 @@
 
 var expect = chai.expect;
 var sharedActions = loop.shared.actions;
 
 describe("loop.store.ActiveRoomStore", function () {
   "use strict";
 
   var ROOM_STATES = loop.store.ROOM_STATES;
-  var sandbox, dispatcher, store, fakeMozLoop;
+  var sandbox, dispatcher, store, fakeMozLoop, fakeSdkDriver;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     sandbox.useFakeTimers();
 
     dispatcher = new loop.Dispatcher();
     sandbox.stub(dispatcher, "dispatch");
 
@@ -20,18 +20,26 @@ describe("loop.store.ActiveRoomStore", f
       rooms: {
         get: sandbox.stub(),
         join: sandbox.stub(),
         refreshMembership: sandbox.stub(),
         leave: sandbox.stub()
       }
     };
 
-    store = new loop.store.ActiveRoomStore(
-      {mozLoop: fakeMozLoop, dispatcher: dispatcher});
+    fakeSdkDriver = {
+      connectSession: sandbox.stub(),
+      disconnectSession: sandbox.stub()
+    };
+
+    store = new loop.store.ActiveRoomStore({
+      dispatcher: dispatcher,
+      mozLoop: fakeMozLoop,
+      sdkDriver: fakeSdkDriver
+    });
   });
 
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("#constructor", function() {
     it("should throw an error if the dispatcher is missing", function() {
@@ -40,16 +48,22 @@ describe("loop.store.ActiveRoomStore", f
       }).to.Throw(/dispatcher/);
     });
 
     it("should throw an error if mozLoop is missing", function() {
       expect(function() {
         new loop.store.ActiveRoomStore({dispatcher: dispatcher});
       }).to.Throw(/mozLoop/);
     });
+
+    it("should throw an error if sdkDriver is missing", function() {
+      expect(function() {
+        new loop.store.ActiveRoomStore({dispatcher: dispatcher, mozLoop: {}});
+      }).to.Throw(/sdkDriver/);
+    });
   });
 
   describe("#roomFailure", function() {
     var fakeError;
 
     beforeEach(function() {
       sandbox.stub(console, "error");
 
@@ -276,16 +290,26 @@ describe("loop.store.ActiveRoomStore", f
       store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
 
       var state = store.getStoreState();
       expect(state.apiKey).eql(fakeJoinedData.apiKey);
       expect(state.sessionToken).eql(fakeJoinedData.sessionToken);
       expect(state.sessionId).eql(fakeJoinedData.sessionId);
     });
 
+    it("should start the session connection with the sdk", function() {
+      var actionData = new sharedActions.JoinedRoom(fakeJoinedData);
+
+      store.joinedRoom(actionData);
+
+      sinon.assert.calledOnce(fakeSdkDriver.connectSession);
+      sinon.assert.calledWithExactly(fakeSdkDriver.connectSession,
+        actionData);
+    });
+
     it("should call mozLoop.rooms.refreshMembership before the expiresTime",
       function() {
         store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
 
         sandbox.clock.tick(fakeJoinedData.expires * 1000);
 
         sinon.assert.calledOnce(fakeMozLoop.rooms.refreshMembership);
         sinon.assert.calledWith(fakeMozLoop.rooms.refreshMembership,
@@ -325,25 +349,118 @@ describe("loop.store.ActiveRoomStore", f
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWith(dispatcher.dispatch,
           new sharedActions.RoomFailure({
             error: fakeError
           }));
     });
   });
 
+  describe("#connectedToSdkServers", function() {
+    it("should set the state to `SESSION_CONNECTED`", function() {
+      store.connectedToSdkServers(new sharedActions.ConnectedToSdkServers());
+
+      expect(store.getStoreState().roomState).eql(ROOM_STATES.SESSION_CONNECTED);
+    });
+  });
+
+  describe("#connectionFailure", function() {
+    beforeEach(function() {
+      store.setStoreState({
+        roomState: ROOM_STATES.JOINED,
+        roomToken: "fakeToken",
+        sessionToken: "1627384950"
+      });
+    });
+
+    it("should disconnect from the servers via the sdk", function() {
+      store.connectionFailure();
+
+      sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
+    });
+
+    it("should clear any existing timeout", function() {
+      sandbox.stub(window, "clearTimeout");
+      store._timeout = {};
+
+      store.connectionFailure();
+
+      sinon.assert.calledOnce(clearTimeout);
+    });
+
+    it("should call mozLoop.rooms.leave", function() {
+      store.connectionFailure();
+
+      sinon.assert.calledOnce(fakeMozLoop.rooms.leave);
+      sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave,
+        "fakeToken", "1627384950");
+    });
+
+    it("should set the state to `FAILED`", function() {
+      store.connectionFailure();
+
+      expect(store.getStoreState().roomState).eql(ROOM_STATES.FAILED);
+    });
+  });
+
+  describe("#setMute", function() {
+    it("should save the mute state for the audio stream", function() {
+      store.setStoreState({audioMuted: false});
+
+      store.setMute(new sharedActions.SetMute({
+        type: "audio",
+        enabled: true
+      }));
+
+      expect(store.getStoreState().audioMuted).eql(false);
+    });
+
+    it("should save the mute state for the video stream", function() {
+      store.setStoreState({videoMuted: true});
+
+      store.setMute(new sharedActions.SetMute({
+        type: "video",
+        enabled: false
+      }));
+
+      expect(store.getStoreState().videoMuted).eql(true);
+    });
+  });
+
+  describe("#remotePeerConnected", function() {
+    it("should set the state to `HAS_PARTICIPANTS`", function() {
+      store.remotePeerConnected();
+
+      expect(store.getStoreState().roomState).eql(ROOM_STATES.HAS_PARTICIPANTS);
+    });
+  });
+
+  describe("#remotePeerDisconnected", function() {
+    it("should set the state to `SESSION_CONNECTED`", function() {
+      store.remotePeerDisconnected();
+
+      expect(store.getStoreState().roomState).eql(ROOM_STATES.SESSION_CONNECTED);
+    });
+  });
+
   describe("#windowUnload", function() {
     beforeEach(function() {
       store.setStoreState({
         roomState: ROOM_STATES.JOINED,
         roomToken: "fakeToken",
         sessionToken: "1627384950"
       });
     });
 
+    it("should disconnect from the servers via the sdk", function() {
+      store.windowUnload();
+
+      sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
+    });
+
     it("should clear any existing timeout", function() {
       sandbox.stub(window, "clearTimeout");
       store._timeout = {};
 
       store.windowUnload();
 
       sinon.assert.calledOnce(clearTimeout);
     });
@@ -367,16 +484,22 @@ describe("loop.store.ActiveRoomStore", f
     beforeEach(function() {
       store.setStoreState({
         roomState: ROOM_STATES.JOINED,
         roomToken: "fakeToken",
         sessionToken: "1627384950"
       });
     });
 
+    it("should disconnect from the servers via the sdk", function() {
+      store.leaveRoom();
+
+      sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
+    });
+
     it("should clear any existing timeout", function() {
       sandbox.stub(window, "clearTimeout");
       store._timeout = {};
 
       store.leaveRoom();
 
       sinon.assert.calledOnce(clearTimeout);
     });
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -295,10 +295,38 @@ describe("loop.OTSdkDriver", function ()
 
         session.trigger("streamCreated", {stream: fakeStream});
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "mediaConnected"));
       });
     });
+
+    describe("connectionCreated", function() {
+      beforeEach(function() {
+        session.connection = {
+          id: "localUser"
+        };
+      });
+
+      it("should dispatch a RemotePeerConnected action if this is for a remote user",
+        function() {
+          session.trigger("connectionCreated", {
+            connection: {id: "remoteUser"}
+          });
+
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.RemotePeerConnected());
+        });
+
+      it("should not dispatch an action if this is for a local user",
+        function() {
+          session.trigger("connectionCreated", {
+            connection: {id: "localUser"}
+          });
+
+          sinon.assert.notCalled(dispatcher.dispatch);
+        });
+    });
   });
 });
--- a/browser/components/loop/test/shared/roomStore_test.js
+++ b/browser/components/loop/test/shared/roomStore_test.js
@@ -365,17 +365,18 @@ describe("loop.store.RoomStore", functio
     });
 
     describe("ActiveRoomStore substore", function() {
       var store, activeRoomStore;
 
       beforeEach(function() {
         activeRoomStore = new loop.store.ActiveRoomStore({
           dispatcher: dispatcher,
-          mozLoop: fakeMozLoop
+          mozLoop: fakeMozLoop,
+          sdkDriver: {}
         });
         store = new loop.store.RoomStore({
           dispatcher: dispatcher,
           mozLoop: fakeMozLoop,
           activeRoomStore: activeRoomStore
         });
       });
 
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -36,16 +36,17 @@
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/shared/js/feedbackApiClient.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
   <script src="../../content/shared/js/activeRoomStore.js"></script>
+  <script src="../../content/shared/js/otSdkDriver.js"></script>
   <script src="../../standalone/content/js/multiplexGum.js"></script>
   <script src="../../standalone/content/js/standaloneAppStore.js"></script>
   <script src="../../standalone/content/js/standaloneClient.js"></script>
   <script src="../../standalone/content/js/standaloneMozLoop.js"></script>
   <script src="../../standalone/content/js/standaloneRoomViews.js"></script>
   <script src="../../standalone/content/js/webapp.js"></script>
   <!-- Test scripts -->
   <script src="standalone_client_test.js"></script>
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -607,17 +607,18 @@ describe("loop.webapp", function() {
         sdk: sdk
       });
       client = new loop.StandaloneClient({
         baseServerUrl: "fakeUrl"
       });
       dispatcher = new loop.Dispatcher();
       activeRoomStore = new loop.store.ActiveRoomStore({
         dispatcher: dispatcher,
-        mozLoop: {}
+        mozLoop: {},
+        sdkDriver: {}
       });
       standaloneAppStore = new loop.store.StandaloneAppStore({
         dispatcher: dispatcher,
         sdk: sdk,
         helper: helper,
         conversation: conversationModel
       });
       // Stub this to stop the StartConversationView kicking in the request and
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -58,17 +58,18 @@
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
   var dispatcher = new loop.Dispatcher();
   var activeRoomStore = new loop.store.ActiveRoomStore({
     dispatcher: dispatcher,
-    mozLoop: navigator.mozLoop
+    mozLoop: navigator.mozLoop,
+    sdkDriver: {}
   });
   var roomStore = new loop.store.RoomStore({
     dispatcher: dispatcher,
     mozLoop: navigator.mozLoop
   });
 
   // Local mocks
 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -58,17 +58,18 @@
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
   var dispatcher = new loop.Dispatcher();
   var activeRoomStore = new loop.store.ActiveRoomStore({
     dispatcher: dispatcher,
-    mozLoop: navigator.mozLoop
+    mozLoop: navigator.mozLoop,
+    sdkDriver: {}
   });
   var roomStore = new loop.store.RoomStore({
     dispatcher: dispatcher,
     mozLoop: navigator.mozLoop
   });
 
   // Local mocks