Bug 1074702 - Part 1 Implement join/refresh/leave with the Loop server on the standalone UI. r=nperriault a=loop-only
authorMark Banner <standard8@mozilla.com>
Fri, 07 Nov 2014 16:28:13 +0000
changeset 233808 eb1a1082bbc81d8c2ac99553007e041208249f25
parent 233807 e7ba3a80159b7d7a0b44bdb5200dab92646418c8
child 233809 08509dd4569a02e52ec025c449327e940e3db654
push id1
push usersledru@mozilla.com
push dateThu, 04 Dec 2014 17:57:20 +0000
reviewersnperriault, loop-only
bugs1074702
milestone35.0a2
Bug 1074702 - Part 1 Implement join/refresh/leave with the Loop server on the standalone UI. r=nperriault a=loop-only
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/standalone/content/index.html
browser/components/loop/standalone/content/js/standaloneMozLoop.js
browser/components/loop/standalone/content/js/standaloneRoomViews.js
browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/standalone/index.html
browser/components/loop/test/standalone/standaloneMozLoop_test.js
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -268,11 +268,17 @@ loop.shared.actions = (function() {
      *
      * @see https://wiki.mozilla.org/Loop/Architecture/Rooms#Joining_a_Room
      */
     JoinedRoom: Action.define("joinedRoom", {
       apiKey: String,
       sessionToken: String,
       sessionId: String,
       expires: Number
+    }),
+
+    /**
+     * Used to indicate the user wishes to leave the room.
+     */
+    LeaveRoom: Action.define("leaveRoom", {
     })
   };
 })();
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -47,20 +47,22 @@ loop.store.ActiveRoomStore = (function()
     if (!options.mozLoop) {
       throw new Error("Missing option mozLoop");
     }
     this._mozLoop = options.mozLoop;
 
     this._dispatcher.register(this, [
       "roomFailure",
       "setupWindowData",
+      "fetchServerData",
       "updateRoomInfo",
       "joinRoom",
       "joinedRoom",
-      "windowUnload"
+      "windowUnload",
+      "leaveRoom"
     ]);
 
     /**
      * Stored data reflecting the local state of a given room, used to drive
      * the room's views.
      *
      * @see https://wiki.mozilla.org/Loop/Architecture/Rooms#GET_.2Frooms.2F.7Btoken.7D
      *      for the main data. Additional properties below.
@@ -147,16 +149,36 @@ loop.store.ActiveRoomStore = (function()
 
           // For the conversation window, we need to automatically
           // join the room.
           this._dispatcher.dispatch(new sharedActions.JoinRoom());
         }.bind(this));
     },
 
     /**
+     * Execute fetchServerData event action from the dispatcher. Although
+     * this is to fetch the server data - for rooms on the standalone client,
+     * we don't actually need to get any data. Therefore we just save the
+     * data that is given to us for when the user chooses to join the room.
+     *
+     * @param {sharedActions.FetchServerData} actionData
+     */
+    fetchServerData: function(actionData) {
+      if (actionData.windowType !== "room") {
+        // Nothing for us to do here, leave it to other stores.
+        return;
+      }
+
+      this.setStoreState({
+        roomToken: actionData.token,
+        roomState: ROOM_STATES.READY
+      });
+    },
+
+    /**
      * Handles the updateRoomInfo action. Updates the room data and
      * sets the state to `READY`.
      *
      * @param {sharedActions.UpdateRoomInfo} actionData
      */
     updateRoomInfo: function(actionData) {
       this.setStoreState({
         roomName: actionData.roomName,
@@ -209,16 +231,23 @@ loop.store.ActiveRoomStore = (function()
     /**
      * Handles the window being unloaded. Ensures the room is left.
      */
     windowUnload: function() {
       this._leaveRoom();
     },
 
     /**
+     * Handles a room being left.
+     */
+    leaveRoom: function() {
+      this._leaveRoom();
+    },
+
+    /**
      * Handles setting of the refresh timeout callback.
      *
      * @param {Integer} expireTime The time until expiry (in seconds).
      */
     _setRefreshTimeout: function(expireTime) {
       this._timeout = setTimeout(this._refreshMembership.bind(this),
         expireTime * this.expiresTimeFactor * 1000);
     },
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -94,16 +94,17 @@
     <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/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>
       // Wait for all the localization notes to load
       window.addEventListener('localized', function() {
         loop.webapp.init();
       }, false);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/standalone/content/js/standaloneMozLoop.js
@@ -0,0 +1,188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true */
+
+/**
+ * The StandaloneMozLoop implementation reflects that of the mozLoop API for Loop
+ * in the desktop code. Not all functions are implemented.
+ */
+var loop = loop || {};
+loop.StandaloneMozLoop = (function(mozL10n) {
+  "use strict";
+
+  /**
+   * The maximum number of clients that we currently support.
+   */
+  var ROOM_MAX_CLIENTS = 2;
+
+
+  /**
+   * Validates a data object to confirm it has the specified properties.
+   *
+   * @param  {Object} data    The data object to verify
+   * @param  {Array}  schema  The validation schema
+   * @return Returns all properties if valid, or an empty object if no properties
+   *         have been specified.
+   */
+  function validate(data, schema) {
+    if (!schema) {
+      return {};
+    }
+
+    return new loop.validate.Validator(schema).validate(data);
+  }
+
+  /**
+   * Generic handler for XHR failures.
+   *
+   * @param {Function} callback Callback(err)
+   * @param jqXHR See jQuery docs
+   * @param textStatus See jQuery docs
+   * @param errorThrown See jQuery docs
+   */
+  function failureHandler(callback, jqXHR, textStatus, errorThrown) {
+    var jsonErr = jqXHR && jqXHR.responseJSON || {};
+    var message = "HTTP " + jqXHR.status + " " + errorThrown;
+
+    // Create an error with server error `errno` code attached as a property
+    var err = new Error(message);
+    err.errno = jsonErr.errno;
+
+    callback(err);
+  }
+
+  /**
+   * StandaloneMozLoopRooms is used as part of StandaloneMozLoop to define
+   * the rooms sub-object. We do it this way so that we can share the options
+   * information from the parent.
+   */
+  var StandaloneMozLoopRooms = function(options) {
+    options = options || {};
+    if (!options.baseServerUrl) {
+      throw new Error("missing required baseServerUrl");
+    }
+
+    this._baseServerUrl = options.baseServerUrl;
+  };
+
+  StandaloneMozLoopRooms.prototype = {
+    /**
+     * Internal function to actually perform a post to a room.
+     *
+     * @param {String} roomToken The rom token.
+     * @param {String} sessionToken The sessionToken for the room if known
+     * @param {Object} roomData The data to send with the request
+     * @param {Array} expectedProps The expected properties we should receive from the
+     *                              server
+     * @param {Function} callback The callback for when the request completes. The
+     *                            first parameter is non-null on error, the second parameter
+     *                            is the response data.
+     */
+    _postToRoom: function(roomToken, sessionToken, roomData, expectedProps, callback) {
+      var req = $.ajax({
+        url:         this._baseServerUrl + "/rooms/" + roomToken,
+        method:      "POST",
+        contentType: "application/json",
+        dataType:    "json",
+        data: JSON.stringify(roomData),
+        beforeSend: function(xhr) {
+          if (sessionToken) {
+            xhr.setRequestHeader("Authorization", "Basic " + btoa(sessionToken));
+          }
+        }
+      });
+
+      req.done(function(responseData) {
+        try {
+          callback(null, validate(responseData, expectedProps));
+        } catch (err) {
+          console.error("Error requesting call info", err.message);
+          callback(err);
+        }
+      }.bind(this));
+
+      req.fail(failureHandler.bind(this, callback));
+    },
+
+    /**
+     * Joins a room
+     *
+     * @param {String} roomToken  The room token.
+     * @param {Function} callback Function that will be invoked once the operation
+     *                            finished. The first argument passed will be an
+     *                            `Error` object or `null`.
+     */
+    join: function(roomToken, callback) {
+      this._postToRoom(roomToken, null, {
+        action: "join",
+        displayName: mozL10n.get("rooms_display_name_guest"),
+        clientMaxSize: ROOM_MAX_CLIENTS
+      }, {
+        apiKey: String,
+        sessionId: String,
+        sessionToken: String,
+        expires: Number
+      }, callback);
+    },
+
+    /**
+     * Refreshes a room
+     *
+     * @param {String} roomToken    The room token.
+     * @param {String} sessionToken The session token for the session that has been
+     *                              joined
+     * @param {Function} callback   Function that will be invoked once the operation
+     *                              finished. The first argument passed will be an
+     *                              `Error` object or `null`.
+     */
+    refreshMembership: function(roomToken, sessionToken, callback) {
+      this._postToRoom(roomToken, sessionToken, {
+        action: "refresh",
+        sessionToken: sessionToken
+      }, {
+        expires: Number
+      }, callback);
+    },
+
+    /**
+     * Leaves a room. Although this is an sync function, no data is returned
+     * from the server.
+     *
+     * @param {String} roomToken    The room token.
+     * @param {String} sessionToken The session token for the session that has been
+     *                              joined
+     * @param {Function} callback   Optional. Function that will be invoked once the operation
+     *                              finished. The first argument passed will be an
+     *                              `Error` object or `null`.
+     */
+    leave: function(roomToken, sessionToken, callback) {
+      if (!callback) {
+        callback = function(error) {
+          if (error) {
+            console.error(error);
+          }
+        };
+      }
+
+      this._postToRoom(roomToken, sessionToken, {
+        action: "leave",
+        sessionToken: sessionToken
+      }, null, callback);
+    }
+  };
+
+  var StandaloneMozLoop = function(options) {
+    options = options || {};
+    if (!options.baseServerUrl) {
+      throw new Error("missing required baseServerUrl");
+    }
+
+    this._baseServerUrl = options.baseServerUrl;
+
+    this.rooms = new StandaloneMozLoopRooms(options);
+  };
+
+  return StandaloneMozLoop;
+})(navigator.mozL10n);
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -5,22 +5,26 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.standaloneRoomViews = (function() {
   "use strict";
 
+  var ROOM_STATES = loop.store.ROOM_STATES;
+  var sharedActions = loop.shared.actions;
+
   var StandaloneRoomView = React.createClass({displayName: 'StandaloneRoomView',
     mixins: [Backbone.Events],
 
     propTypes: {
       activeRoomStore:
-        React.PropTypes.instanceOf(loop.store.ActiveRoomStore).isRequired
+        React.PropTypes.instanceOf(loop.store.ActiveRoomStore).isRequired,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
     },
 
     getInitialState: function() {
       return this.props.activeRoomStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.activeRoomStore, "change",
@@ -36,21 +40,43 @@ loop.standaloneRoomViews = (function() {
     _onActiveRoomStateChanged: function() {
       this.setState(this.props.activeRoomStore.getStoreState());
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.activeRoomStore);
     },
 
+    joinRoom: function() {
+      this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
+    },
+
+    leaveRoom: function() {
+      this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
+    },
+
+    // XXX Implement tests for this view when we do the proper views
+    // - bug 1074705 and others
     render: function() {
-      return (
-        React.DOM.div(null, 
-          React.DOM.div(null, this.state.roomState)
-        )
-      );
+      switch(this.state.roomState) {
+        case ROOM_STATES.READY: {
+          return (
+            React.DOM.div(null, React.DOM.button({onClick: this.joinRoom}, "Join"))
+          );
+        }
+        case ROOM_STATES.JOINED: {
+          return (
+            React.DOM.div(null, React.DOM.button({onClick: this.leaveRoom}, "Leave"))
+          );
+        }
+        default: {
+          return (
+            React.DOM.div(null, this.state.roomState)
+          );
+        }
+      }
     }
   });
 
   return {
     StandaloneRoomView: StandaloneRoomView
   };
 })();
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -5,22 +5,26 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.standaloneRoomViews = (function() {
   "use strict";
 
+  var ROOM_STATES = loop.store.ROOM_STATES;
+  var sharedActions = loop.shared.actions;
+
   var StandaloneRoomView = React.createClass({
     mixins: [Backbone.Events],
 
     propTypes: {
       activeRoomStore:
-        React.PropTypes.instanceOf(loop.store.ActiveRoomStore).isRequired
+        React.PropTypes.instanceOf(loop.store.ActiveRoomStore).isRequired,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
     },
 
     getInitialState: function() {
       return this.props.activeRoomStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.activeRoomStore, "change",
@@ -36,21 +40,43 @@ loop.standaloneRoomViews = (function() {
     _onActiveRoomStateChanged: function() {
       this.setState(this.props.activeRoomStore.getStoreState());
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.activeRoomStore);
     },
 
+    joinRoom: function() {
+      this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
+    },
+
+    leaveRoom: function() {
+      this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
+    },
+
+    // XXX Implement tests for this view when we do the proper views
+    // - bug 1074705 and others
     render: function() {
-      return (
-        <div>
-          <div>{this.state.roomState}</div>
-        </div>
-      );
+      switch(this.state.roomState) {
+        case ROOM_STATES.READY: {
+          return (
+            <div><button onClick={this.joinRoom}>Join</button></div>
+          );
+        }
+        case ROOM_STATES.JOINED: {
+          return (
+            <div><button onClick={this.leaveRoom}>Leave</button></div>
+          );
+        }
+        default: {
+          return (
+            <div>{this.state.roomState}</div>
+          );
+        }
+      }
     }
   });
 
   return {
     StandaloneRoomView: StandaloneRoomView
   };
 })();
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -874,17 +874,18 @@ loop.webapp = (function($, _, OT, mozL10
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
       feedbackApiClient: React.PropTypes.object.isRequired,
 
       // XXX New types for flux style
       standaloneAppStore: React.PropTypes.instanceOf(
         loop.store.StandaloneAppStore).isRequired,
       activeRoomStore: React.PropTypes.instanceOf(
-        loop.store.ActiveRoomStore).isRequired
+        loop.store.ActiveRoomStore).isRequired,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
     },
 
     getInitialState: function() {
       return this.props.standaloneAppStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.standaloneAppStore, "change", function() {
@@ -918,17 +919,18 @@ loop.webapp = (function($, _, OT, mozL10
                sdk: this.props.sdk, 
                feedbackApiClient: this.props.feedbackApiClient}
             )
           );
         }
         case "room": {
           return (
             loop.standaloneRoomViews.StandaloneRoomView({
-              activeRoomStore: this.props.activeRoomStore}
+              activeRoomStore: this.props.activeRoomStore, 
+              dispatcher: this.props.dispatcher}
             )
           );
         }
         case "home": {
           return HomeView(null);
         }
         default: {
           // The state hasn't been initialised yet, so don't display
@@ -939,16 +941,19 @@ loop.webapp = (function($, _, OT, mozL10
     }
   });
 
   /**
    * App initialization.
    */
   function init() {
     var helper = new sharedUtils.Helper();
+    var standaloneMozLoop = new loop.StandaloneMozLoop({
+      baseServerUrl: loop.config.serverUrl
+    });
 
     // Older non-flux based items.
     var notifications = new sharedModels.NotificationCollection();
     var conversation
     if (helper.isFirefoxOS(navigator.userAgent)) {
       conversation = new FxOSConversationModel();
     } else {
       conversation = new sharedModels.ConversationModel({}, {
@@ -972,30 +977,33 @@ loop.webapp = (function($, _, OT, mozL10
     var standaloneAppStore = new loop.store.StandaloneAppStore({
       conversation: conversation,
       dispatcher: dispatcher,
       helper: helper,
       sdk: OT
     });
     var activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      // XXX Bug 1074702 will introduce a mozLoop compatible object for
-      // the standalone rooms.
-      mozLoop: {}
+      mozLoop: standaloneMozLoop
+    });
+
+    window.addEventListener("unload", function() {
+      dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(WebappRootView({
       client: client, 
       conversation: conversation, 
       helper: helper, 
       notifications: notifications, 
       sdk: OT, 
       feedbackApiClient: feedbackApiClient, 
       standaloneAppStore: standaloneAppStore, 
-      activeRoomStore: activeRoomStore}
+      activeRoomStore: activeRoomStore, 
+      dispatcher: dispatcher}
     ), document.querySelector("#main"));
 
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
     document.title = mozL10n.get("clientShortname2");
 
     dispatcher.dispatch(new sharedActions.ExtractTokenInfo({
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -874,17 +874,18 @@ loop.webapp = (function($, _, OT, mozL10
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
       feedbackApiClient: React.PropTypes.object.isRequired,
 
       // XXX New types for flux style
       standaloneAppStore: React.PropTypes.instanceOf(
         loop.store.StandaloneAppStore).isRequired,
       activeRoomStore: React.PropTypes.instanceOf(
-        loop.store.ActiveRoomStore).isRequired
+        loop.store.ActiveRoomStore).isRequired,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
     },
 
     getInitialState: function() {
       return this.props.standaloneAppStore.getStoreState();
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.standaloneAppStore, "change", function() {
@@ -919,16 +920,17 @@ loop.webapp = (function($, _, OT, mozL10
                feedbackApiClient={this.props.feedbackApiClient}
             />
           );
         }
         case "room": {
           return (
             <loop.standaloneRoomViews.StandaloneRoomView
               activeRoomStore={this.props.activeRoomStore}
+              dispatcher={this.props.dispatcher}
             />
           );
         }
         case "home": {
           return <HomeView />;
         }
         default: {
           // The state hasn't been initialised yet, so don't display
@@ -939,16 +941,19 @@ loop.webapp = (function($, _, OT, mozL10
     }
   });
 
   /**
    * App initialization.
    */
   function init() {
     var helper = new sharedUtils.Helper();
+    var standaloneMozLoop = new loop.StandaloneMozLoop({
+      baseServerUrl: loop.config.serverUrl
+    });
 
     // Older non-flux based items.
     var notifications = new sharedModels.NotificationCollection();
     var conversation
     if (helper.isFirefoxOS(navigator.userAgent)) {
       conversation = new FxOSConversationModel();
     } else {
       conversation = new sharedModels.ConversationModel({}, {
@@ -972,30 +977,33 @@ loop.webapp = (function($, _, OT, mozL10
     var standaloneAppStore = new loop.store.StandaloneAppStore({
       conversation: conversation,
       dispatcher: dispatcher,
       helper: helper,
       sdk: OT
     });
     var activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      // XXX Bug 1074702 will introduce a mozLoop compatible object for
-      // the standalone rooms.
-      mozLoop: {}
+      mozLoop: standaloneMozLoop
+    });
+
+    window.addEventListener("unload", function() {
+      dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(<WebappRootView
       client={client}
       conversation={conversation}
       helper={helper}
       notifications={notifications}
       sdk={OT}
       feedbackApiClient={feedbackApiClient}
       standaloneAppStore={standaloneAppStore}
       activeRoomStore={activeRoomStore}
+      dispatcher={dispatcher}
     />, document.querySelector("#main"));
 
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
     document.title = mozL10n.get("clientShortname2");
 
     dispatcher.dispatch(new sharedActions.ExtractTokenInfo({
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -154,36 +154,56 @@ describe("loop.store.ActiveRoomStore", f
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.RoomFailure({
             error: fakeError
           }));
       });
   });
 
+  describe("#fetchServerData", function() {
+    it("should save the token", function() {
+      store.fetchServerData(new sharedActions.FetchServerData({
+        windowType: "room",
+        token: "fakeToken"
+      }));
+
+      expect(store.getStoreState().roomToken).eql("fakeToken");
+    });
+
+    it("should set the state to `READY`", function() {
+      store.fetchServerData(new sharedActions.FetchServerData({
+        windowType: "room",
+        token: "fakeToken"
+      }));
+
+      expect(store.getStoreState().roomState).eql(ROOM_STATES.READY);
+    });
+  });
+
   describe("#updateRoomInfo", function() {
     var fakeRoomInfo;
 
     beforeEach(function() {
       fakeRoomInfo = {
         roomName: "Its a room",
         roomOwner: "Me",
         roomToken: "fakeToken",
         roomUrl: "http://invalid"
       };
     });
 
     it("should set the state to READY", function() {
-      store.updateRoomInfo(fakeRoomInfo);
+      store.updateRoomInfo(new sharedActions.UpdateRoomInfo(fakeRoomInfo));
 
       expect(store._storeState.roomState).eql(ROOM_STATES.READY);
     });
 
     it("should save the room information", function() {
-      store.updateRoomInfo(fakeRoomInfo);
+      store.updateRoomInfo(new sharedActions.UpdateRoomInfo(fakeRoomInfo));
 
       var state = store.getStoreState();
       expect(state.roomName).eql(fakeRoomInfo.roomName);
       expect(state.roomOwner).eql(fakeRoomInfo.roomOwner);
       expect(state.roomToken).eql(fakeRoomInfo.roomToken);
       expect(state.roomUrl).eql(fakeRoomInfo.roomUrl);
     });
   });
@@ -242,47 +262,47 @@ describe("loop.store.ActiveRoomStore", f
       };
 
       store.setStoreState({
         roomToken: "fakeToken"
       });
     });
 
     it("should set the state to `JOINED`", function() {
-      store.joinedRoom(fakeJoinedData);
+      store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
 
       expect(store._storeState.roomState).eql(ROOM_STATES.JOINED);
     });
 
     it("should store the session and api values", function() {
-      store.joinedRoom(fakeJoinedData);
+      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 call mozLoop.rooms.refreshMembership before the expiresTime",
       function() {
-        store.joinedRoom(fakeJoinedData);
+        store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
 
         sandbox.clock.tick(fakeJoinedData.expires * 1000);
 
         sinon.assert.calledOnce(fakeMozLoop.rooms.refreshMembership);
         sinon.assert.calledWith(fakeMozLoop.rooms.refreshMembership,
           "fakeToken", "12563478");
     });
 
     it("should call mozLoop.rooms.refreshMembership before the next expiresTime",
       function() {
         fakeMozLoop.rooms.refreshMembership.callsArgWith(2,
           null, {expires: 40});
 
-        store.joinedRoom(fakeJoinedData);
+        store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
 
         // Clock tick for the first expiry time (which
         // sets up the refreshMembership).
         sandbox.clock.tick(fakeJoinedData.expires * 1000);
 
         // Clock tick for expiry time in the refresh membership response.
         sandbox.clock.tick(40000);
 
@@ -291,17 +311,17 @@ describe("loop.store.ActiveRoomStore", f
           "fakeToken", "12563478");
     });
 
     it("should dispatch `RoomFailure` if the refreshMembership call failed",
       function() {
         var fakeError = new Error("fake");
         fakeMozLoop.rooms.refreshMembership.callsArgWith(2, fakeError);
 
-        store.joinedRoom(fakeJoinedData);
+        store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
 
         // Clock tick for the first expiry time (which
         // sets up the refreshMembership).
         sandbox.clock.tick(fakeJoinedData.expires * 1000);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWith(dispatcher.dispatch,
           new sharedActions.RoomFailure({
@@ -337,9 +357,42 @@ describe("loop.store.ActiveRoomStore", f
     });
 
     it("should set the state to ready", function() {
       store.windowUnload();
 
       expect(store._storeState.roomState).eql(ROOM_STATES.READY);
     });
   });
+
+  describe("#leaveRoom", function() {
+    beforeEach(function() {
+      store.setStoreState({
+        roomState: ROOM_STATES.JOINED,
+        roomToken: "fakeToken",
+        sessionToken: "1627384950"
+      });
+    });
+
+    it("should clear any existing timeout", function() {
+      sandbox.stub(window, "clearTimeout");
+      store._timeout = {};
+
+      store.leaveRoom();
+
+      sinon.assert.calledOnce(clearTimeout);
+    });
+
+    it("should call mozLoop.rooms.leave", function() {
+      store.leaveRoom();
+
+      sinon.assert.calledOnce(fakeMozLoop.rooms.leave);
+      sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave,
+        "fakeToken", "1627384950");
+    });
+
+    it("should set the state to ready", function() {
+      store.leaveRoom();
+
+      expect(store._storeState.roomState).eql(ROOM_STATES.READY);
+    });
+  });
 });
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -39,21 +39,23 @@
   <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="../../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 -->
+  <!-- Test scripts -->
   <script src="standalone_client_test.js"></script>
   <script src="standaloneAppStore_test.js"></script>
+  <script src="standaloneMozLoop_test.js"></script>
   <script src="webapp_test.js"></script>
   <script src="multiplexGum_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
 </script>
 </body>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/standalone/standaloneMozLoop_test.js
@@ -0,0 +1,178 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var expect = chai.expect;
+
+describe("loop.StandaloneMozLoop", function() {
+  "use strict";
+
+  var sandbox, fakeXHR, requests, callback, mozLoop;
+  var fakeToken, fakeBaseServerUrl, fakeServerErrorDescription;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+    fakeXHR = sandbox.useFakeXMLHttpRequest();
+    requests = [];
+    // https://github.com/cjohansen/Sinon.JS/issues/393
+    fakeXHR.xhr.onCreate = function (xhr) {
+      requests.push(xhr);
+    };
+    fakeBaseServerUrl = "http://fake.api";
+    fakeServerErrorDescription = {
+      code: 401,
+      errno: 101,
+      error: "error",
+      message: "invalid token",
+      info: "error info"
+    };
+
+    callback = sinon.spy();
+
+    mozLoop = new loop.StandaloneMozLoop({
+      baseServerUrl: fakeBaseServerUrl
+    });
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("#constructor", function() {
+    it("should require a baseServerUrl setting", function() {
+      expect(function() {
+        new loop.StandaloneMozLoop();
+      }).to.Throw(Error, /required/);
+    });
+  });
+
+  describe("#rooms.join", function() {
+    it("should POST to the server", function() {
+      mozLoop.rooms.join("fakeToken", callback);
+
+      expect(requests).to.have.length.of(1);
+      expect(requests[0].url).eql(fakeBaseServerUrl + "/rooms/fakeToken");
+      expect(requests[0].method).eql("POST");
+
+      var requestData = JSON.parse(requests[0].requestBody);
+      expect(requestData.action).eql("join");
+    });
+
+    it("should call the callback with success parameters", function() {
+      mozLoop.rooms.join("fakeToken", callback);
+
+      var sessionData = {
+        apiKey: "12345",
+        sessionId: "54321",
+        sessionToken: "another token",
+        expires: 20
+      };
+
+      requests[0].respond(200, {"Content-Type": "application/json"},
+        JSON.stringify(sessionData));
+
+      sinon.assert.calledOnce(callback);
+      sinon.assert.calledWithExactly(callback, null, sessionData);
+    });
+
+    it("should call the callback with failure parameters", function() {
+      mozLoop.rooms.join("fakeToken", callback);
+
+      requests[0].respond(401, {"Content-Type": "application/json"},
+                          JSON.stringify(fakeServerErrorDescription));
+      sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
+        return /HTTP 401 Unauthorized/.test(err.message);
+      }));
+    });
+  });
+
+  describe("#rooms.refreshMembership", function() {
+    var mozLoop, fakeServerErrorDescription;
+
+    beforeEach(function() {
+      mozLoop = new loop.StandaloneMozLoop({
+        baseServerUrl: fakeBaseServerUrl
+      });
+
+      fakeServerErrorDescription = {
+        code: 401,
+        errno: 101,
+        error: "error",
+        message: "invalid token",
+        info: "error info"
+      };
+    });
+
+    it("should POST to the server", function() {
+      mozLoop.rooms.refreshMembership("fakeToken", "fakeSessionToken", callback);
+
+      expect(requests).to.have.length.of(1);
+      expect(requests[0].url).eql(fakeBaseServerUrl + "/rooms/fakeToken");
+      expect(requests[0].method).eql("POST");
+      expect(requests[0].requestHeaders.Authorization)
+        .eql("Basic " + btoa("fakeSessionToken"));
+
+      var requestData = JSON.parse(requests[0].requestBody);
+      expect(requestData.action).eql("refresh");
+      expect(requestData.sessionToken).eql("fakeSessionToken");
+    });
+
+    it("should call the callback with success parameters", function() {
+      mozLoop.rooms.refreshMembership("fakeToken", "fakeSessionToken", callback);
+
+      var responseData = {
+        expires: 20
+      };
+
+      requests[0].respond(200, {"Content-Type": "application/json"},
+        JSON.stringify(responseData));
+
+      sinon.assert.calledOnce(callback);
+      sinon.assert.calledWithExactly(callback, null, responseData);
+    });
+
+    it("should call the callback with failure parameters", function() {
+      mozLoop.rooms.refreshMembership("fakeToken", "fakeSessionToken", callback);
+
+      requests[0].respond(401, {"Content-Type": "application/json"},
+                          JSON.stringify(fakeServerErrorDescription));
+      sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
+        return /HTTP 401 Unauthorized/.test(err.message);
+      }));
+    });
+  });
+
+  describe("#rooms.leave", function() {
+    it("should POST to the server", function() {
+      mozLoop.rooms.leave("fakeToken", "fakeSessionToken", callback);
+
+      expect(requests).to.have.length.of(1);
+      expect(requests[0].url).eql(fakeBaseServerUrl + "/rooms/fakeToken");
+      expect(requests[0].method).eql("POST");
+      expect(requests[0].requestHeaders.Authorization)
+        .eql("Basic " + btoa("fakeSessionToken"));
+
+      var requestData = JSON.parse(requests[0].requestBody);
+      expect(requestData.action).eql("leave");
+    });
+
+    it("should call the callback with success parameters", function() {
+      mozLoop.rooms.leave("fakeToken", "fakeSessionToken", callback);
+
+      requests[0].respond(204);
+
+      sinon.assert.calledOnce(callback);
+      sinon.assert.calledWithExactly(callback, null, {});
+    });
+
+    it("should call the callback with failure parameters", function() {
+      mozLoop.rooms.leave("fakeToken", "fakeSessionToken", callback);
+
+      requests[0].respond(401, {"Content-Type": "application/json"},
+                          JSON.stringify(fakeServerErrorDescription));
+      sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
+        return /HTTP 401 Unauthorized/.test(err.message);
+      }));
+    });
+  });
+});