Bug 1074702 - Part 1 Implement join/refresh/leave with the Loop server on the standalone UI. r=nperriault
authorMark Banner <standard8@mozilla.com>
Fri, 07 Nov 2014 16:28:13 +0000
changeset 214663 1cbe75fc5bbc6ede09e0eb9ff0621d36249d590e
parent 214623 4beef3d7299c5b1533aa5782e83b3ecf2d28c135
child 214664 fc30354927a2a7d3a9ebf71ddc650506005a362f
push id27791
push userkwierso@gmail.com
push dateSat, 08 Nov 2014 01:43:47 +0000
treeherdermozilla-central@b7f2bf6856a2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnperriault
bugs1074702
milestone36.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1074702 - Part 1 Implement join/refresh/leave with the Loop server on the standalone UI. r=nperriault
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/standalone/content/l10n/loop.en-US.properties
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
@@ -888,17 +888,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() {
@@ -932,17 +933,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
@@ -953,16 +955,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({}, {
@@ -986,30 +991,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
@@ -888,17 +888,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() {
@@ -933,16 +934,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
@@ -953,16 +955,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({}, {
@@ -986,30 +991,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/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -110,16 +110,17 @@ rooms_name_this_room_label=Name this con
 rooms_new_room_button_label=Start a conversation
 rooms_only_occupant_label=You're the first one here.
 rooms_panel_title=Choose a conversation or start a new one
 rooms_room_full_label=There are already two people in this conversation.
 rooms_room_full_call_to_action_nonFx_label=Download {{brandShortname}} to start your own
 rooms_room_full_call_to_action_label=Learn more about {{clientShortname}} »
 rooms_room_joined_label=Someone has joined the conversation!
 rooms_room_join_label=Join the conversation
+rooms_display_name_guest=Guest
 
 ## LOCALIZATION_NOTE(standalone_title_with_status): {{clientShortname}} will be
 ## replaced by the brand name and {{currentStatus}} will be replaced
 ## by the current call status (Connecting, Ringing, etc.)
 standalone_title_with_status={{clientShortname}} — {{currentStatus}}
 status_in_conversation=In conversation
 status_conversation_ended=Conversation ended
 status_error=Something went wrong
--- 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);
+      }));
+    });
+  });
+});