Bug 1171962 - introduce telemetry histogram that counts the amount of sessions that exchanged one or more chat messages. r=vladan,dmose. a=sylvestre
authorMike de Boer <mdeboer@mozilla.com>
Thu, 01 Oct 2015 11:35:31 +0200
changeset 296201 7bbe47be74935f14623561b1a871a8454f2713ec
parent 296200 96c8de96e3cea88d30e0070994f7dd780bc0b2b0
child 296202 ebf46987bc2647ccc61ed0432938584bf4db3716
push id5245
push userraliiev@mozilla.com
push dateThu, 29 Oct 2015 11:30:51 +0000
treeherdermozilla-beta@dac831dc1bd0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersvladan, dmose, sylvestre
bugs1171962
milestone43.0a2
Bug 1171962 - introduce telemetry histogram that counts the amount of sessions that exchanged one or more chat messages. r=vladan,dmose. a=sylvestre
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/content/shared/js/dispatcher.js
browser/components/loop/content/shared/js/textChatStore.js
browser/components/loop/content/shared/js/textChatView.js
browser/components/loop/content/shared/js/textChatView.jsx
browser/components/loop/content/shared/js/utils.js
browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/shared/dispatcher_test.js
browser/components/loop/test/shared/otSdkDriver_test.js
browser/components/loop/test/shared/textChatStore_test.js
browser/components/loop/test/shared/textChatView_test.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
toolkit/components/telemetry/Histograms.json
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -32,16 +32,17 @@ loop.store.ROOM_STATES = {
     CLOSING: "room-closing"
 };
 
 loop.store.ActiveRoomStore = (function() {
   "use strict";
 
   var sharedActions = loop.shared.actions;
   var crypto = loop.crypto;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
   var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
 
   // Error numbers taken from
   // https://github.com/mozilla-services/loop-server/blob/master/loop/errno.json
   var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
 
   var ROOM_STATES = loop.store.ROOM_STATES;
@@ -101,16 +102,17 @@ loop.store.ActiveRoomStore = (function()
      * due to user choice, failure or other reason. It is a subset of
      * getInitialStoreState as some items (e.g. roomState, failureReason,
      * context information) can persist across room exit & re-entry.
      *
      * @type {Array}
      */
     _statesToResetOnLeave: [
       "audioMuted",
+      "chatMessageExchanged",
       "localSrcMediaElement",
       "localVideoDimensions",
       "mediaConnected",
       "receivingScreenShare",
       "remoteSrcMediaElement",
       "remoteVideoDimensions",
       "remoteVideoEnabled",
       "screenSharingState",
@@ -148,17 +150,20 @@ loop.store.ActiveRoomStore = (function()
         roomDescription: null,
         // Room information failed to be obtained for a reason. See ROOM_INFO_FAILURES.
         roomInfoFailure: null,
         // The name of the room.
         roomName: null,
         // Social API state.
         socialShareProviders: null,
         // True if media has been connected both-ways.
-        mediaConnected: false
+        mediaConnected: false,
+        // True if a chat message was sent or received during a session.
+        // Read more at https://wiki.mozilla.org/Loop/Session.
+        chatMessageExchanged: false
       };
     },
 
     /**
      * Handles a room failure.
      *
      * @param {sharedActions.RoomFailure} actionData
      */
@@ -229,17 +234,17 @@ loop.store.ActiveRoomStore = (function()
       // before we know what type we are, but in some cases we need to re-do
       // an action (e.g. FetchServerData).
       if (this._registeredActions) {
         return;
       }
 
       this._registeredActions = true;
 
-      this.dispatcher.register(this, [
+      var actions = [
         "roomFailure",
         "retryAfterRoomFailure",
         "setupRoomInfo",
         "updateRoomInfo",
         "gotMediaPermission",
         "joinRoom",
         "joinedRoom",
         "connectedToSdkServers",
@@ -256,17 +261,24 @@ loop.store.ActiveRoomStore = (function()
         "mediaStreamDestroyed",
         "remoteVideoStatus",
         "videoDimensionsChanged",
         "startScreenShare",
         "endScreenShare",
         "updateSocialShareInfo",
         "connectionStatus",
         "mediaConnected"
-      ]);
+      ];
+      // Register actions that are only used on Desktop.
+      if (this._isDesktop) {
+        // 'receivedTextChatMessage' and  'sendTextChatMessage' actions are only
+        // registered for Telemetry. Once measured, they're unregistered.
+        actions.push("receivedTextChatMessage", "sendTextChatMessage");
+      }
+      this.dispatcher.register(this, actions);
     },
 
     /**
      * Execute setupWindowData event action from the dispatcher. This gets
      * the room data from the mozLoop api, and dispatches an UpdateRoomInfo event.
      * It also dispatches JoinRoom as this action is only applicable to the desktop
      * client, and needs to auto-join.
      *
@@ -979,13 +991,57 @@ loop.store.ActiveRoomStore = (function()
       // NOTE: in the future, when multiple remote video streams are supported,
       //       we'll need to make this support multiple remotes as well. Good
       //       starting point for video tiling.
       var storeProp = (actionData.isLocal ? "local" : "remote") + "VideoDimensions";
       var nextState = {};
       nextState[storeProp] = this.getStoreState()[storeProp];
       nextState[storeProp][actionData.videoType] = actionData.dimensions;
       this.setStoreState(nextState);
+    },
+
+    /**
+     * Handles chat messages received and/ or about to send. If this is the first
+     * chat message for the current session, register a count with telemetry.
+     * It will unhook the listeners when the telemetry criteria have been
+     * fulfilled to make sure we remain lean.
+     * Note: the 'receivedTextChatMessage' and 'sendTextChatMessage' actions are
+     *       only registered on Desktop.
+     *
+     * @param  {sharedActions.ReceivedTextChatMessage|SendTextChatMessage} actionData
+     */
+    _handleTextChatMessage: function(actionData) {
+      if (!this._isDesktop || this.getStoreState().chatMessageExchanged ||
+          actionData.contentType !== CHAT_CONTENT_TYPES.TEXT) {
+        return;
+      }
+
+      this.setStoreState({ chatMessageExchanged: true });
+      // There's no need to listen to these actions anymore.
+      this.dispatcher.unregister(this, [
+        "receivedTextChatMessage",
+        "sendTextChatMessage"
+      ]);
+      // Ping telemetry of this session with successful message(s) exchange.
+      this._mozLoop.telemetryAddValue("LOOP_ROOM_SESSION_WITHCHAT", 1);
+    },
+
+    /**
+     * Handles received text chat messages. For telemetry purposes only.
+     *
+     * @param {sharedActions.ReceivedTextChatMessage} actionData
+     */
+    receivedTextChatMessage: function(actionData) {
+      this._handleTextChatMessage(actionData);
+    },
+
+    /**
+     * Handles sending of a chat message. For telemetry purposes only.
+     *
+     * @param {sharedActions.SendTextChatMessage} actionData
+     */
+    sendTextChatMessage: function(actionData) {
+      this._handleTextChatMessage(actionData);
     }
   });
 
   return ActiveRoomStore;
 })();
--- a/browser/components/loop/content/shared/js/dispatcher.js
+++ b/browser/components/loop/content/shared/js/dispatcher.js
@@ -34,16 +34,38 @@ loop.Dispatcher = (function() {
           this._eventData[type].push(store);
         } else {
           this._eventData[type] = [store];
         }
       }.bind(this));
     },
 
     /**
+     * Unregister a store from receiving notifications of specific actions.
+     *
+     * @param {Object} store The store object to unregister
+     * @param {Array} eventTypes An array of action names
+     */
+    unregister: function(store, eventTypes) {
+      eventTypes.forEach(function(type) {
+        if (!this._eventData.hasOwnProperty(type)) {
+          return;
+        }
+        var idx = this._eventData[type].indexOf(store);
+        if (idx === -1) {
+          return;
+        }
+        this._eventData[type].splice(idx, 1);
+        if (!this._eventData[type].length) {
+          delete this._eventData[type];
+        }
+      }.bind(this));
+    },
+
+    /**
      * Dispatches an action to all registered stores.
      */
     dispatch: function(action) {
       // Always put it on the queue, to make it simpler.
       this._actionQueue.push(action);
       this._dispatchNextAction();
     },
 
--- a/browser/components/loop/content/shared/js/textChatStore.js
+++ b/browser/components/loop/content/shared/js/textChatStore.js
@@ -11,21 +11,17 @@ loop.store.TextChatStore = (function() {
   var sharedActions = loop.shared.actions;
 
   var CHAT_MESSAGE_TYPES = loop.store.CHAT_MESSAGE_TYPES = {
     RECEIVED: "recv",
     SENT: "sent",
     SPECIAL: "special"
   };
 
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES = {
-    CONTEXT: "chat-context",
-    TEXT: "chat-text",
-    ROOM_NAME: "room-name"
-  };
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
 
   /**
    * A store to handle text chats. The store has a message list that may
    * contain different types of messages and data.
    */
   var TextChatStore = loop.store.createStore({
     actions: [
       "dataChannelsAvailable",
--- a/browser/components/loop/content/shared/js/textChatView.js
+++ b/browser/components/loop/content/shared/js/textChatView.js
@@ -7,17 +7,17 @@ loop.shared = loop.shared || {};
 loop.shared.views = loop.shared.views || {};
 loop.shared.views.chat = (function(mozL10n) {
   "use strict";
 
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedViews = loop.shared.views;
   var CHAT_MESSAGE_TYPES = loop.store.CHAT_MESSAGE_TYPES;
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
 
   /**
    * Renders an individual entry for the text chat entries view.
    */
   var TextChatEntry = React.createClass({displayName: "TextChatEntry",
     mixins: [React.addons.PureRenderMixin],
 
     propTypes: {
--- a/browser/components/loop/content/shared/js/textChatView.jsx
+++ b/browser/components/loop/content/shared/js/textChatView.jsx
@@ -7,17 +7,17 @@ loop.shared = loop.shared || {};
 loop.shared.views = loop.shared.views || {};
 loop.shared.views.chat = (function(mozL10n) {
   "use strict";
 
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedViews = loop.shared.views;
   var CHAT_MESSAGE_TYPES = loop.store.CHAT_MESSAGE_TYPES;
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
 
   /**
    * Renders an individual entry for the text chat entries view.
    */
   var TextChatEntry = React.createClass({
     mixins: [React.addons.PureRenderMixin],
 
     propTypes: {
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -102,16 +102,22 @@ var inChrome = typeof Components != "und
 
   var SCREEN_SHARE_STATES = {
     INACTIVE: "ss-inactive",
     // Pending is when the user is being prompted, aka gUM in progress.
     PENDING: "ss-pending",
     ACTIVE: "ss-active"
   };
 
+  var CHAT_CONTENT_TYPES = {
+    CONTEXT: "chat-context",
+    TEXT: "chat-text",
+    ROOM_NAME: "room-name"
+  };
+
   /**
    * Format a given date into an l10n-friendly string.
    *
    * @param {Integer} The timestamp in seconds to format.
    * @return {String} The formatted string.
    */
   function formatDate(timestamp) {
     var date = (new Date(timestamp * 1000));
@@ -767,16 +773,17 @@ var inChrome = typeof Components != "und
       parentNode = parentNode.parentNode;
     }
 
     return node;
   }
 
   this.utils = {
     CALL_TYPES: CALL_TYPES,
+    CHAT_CONTENT_TYPES: CHAT_CONTENT_TYPES,
     FAILURE_DETAILS: FAILURE_DETAILS,
     REST_ERRNOS: REST_ERRNOS,
     WEBSOCKET_REASONS: WEBSOCKET_REASONS,
     STREAM_PROPERTIES: STREAM_PROPERTIES,
     SCREEN_SHARE_STATES: SCREEN_SHARE_STATES,
     ROOM_INFO_FAILURES: ROOM_INFO_FAILURES,
     setRootObjects: setRootObjects,
     composeCallUrlEmail: composeCallUrlEmail,
--- a/browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js
+++ b/browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js
@@ -167,13 +167,27 @@ add_task(function* test_mozLoop_telemetr
 add_task(function* test_mozLoop_telemetryAdd_roomContextClick() {
   let histogramId = "LOOP_ROOM_CONTEXT_CLICK";
   let histogram = Services.telemetry.getHistogramById(histogramId);
 
   histogram.clear();
 
   let snapshot;
   for (let i = 1; i < 4; ++i) {
-    gMozLoopAPI.telemetryAddValue("LOOP_ROOM_CONTEXT_CLICK", 1);
+    gMozLoopAPI.telemetryAddValue(histogramId, 1);
     snapshot = histogram.snapshot();
     Assert.strictEqual(snapshot.counts[0], i);
   }
 });
+
+add_task(function* test_mozLoop_telemetryAdd_roomSessionWithChat() {
+  let histogramId = "LOOP_ROOM_SESSION_WITHCHAT";
+  let histogram = Services.telemetry.getHistogramById(histogramId);
+
+  histogram.clear();
+
+  let snapshot;
+  for (let i = 1; i < 4; ++i) {
+    gMozLoopAPI.telemetryAddValue(histogramId, 1);
+    snapshot = histogram.snapshot();
+    Assert.strictEqual(snapshot.counts[0], i);
+  }
+});
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -3,16 +3,17 @@
 
 describe("loop.store.ActiveRoomStore", function () {
   "use strict";
 
   var expect = chai.expect;
   var sharedActions = loop.shared.actions;
   var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
   var ROOM_STATES = loop.store.ROOM_STATES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
   var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
   var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
   var sandbox, dispatcher, store, fakeMozLoop, fakeSdkDriver, fakeMultiplexGum;
   var standaloneMediaRestore;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
@@ -33,17 +34,18 @@ describe("loop.store.ActiveRoomStore", f
         refreshMembership: sinon.stub(),
         leave: sinon.stub(),
         on: sinon.stub(),
         off: sinon.stub(),
         sendConnectionStatus: sinon.stub()
       },
       setScreenShareState: sinon.stub(),
       getActiveTabWindowId: sandbox.stub().callsArgWith(0, null, 42),
-      getSocialShareProviders: sinon.stub().returns([])
+      getSocialShareProviders: sinon.stub().returns([]),
+      telemetryAddValue: sinon.stub()
     };
 
     fakeSdkDriver = {
       connectSession: sinon.stub(),
       disconnectSession: sinon.stub(),
       forceDisconnectAll: sinon.stub().callsArg(0),
       retryPublishWithoutVideo: sinon.stub(),
       startScreenShare: sinon.stub(),
@@ -1550,27 +1552,29 @@ describe("loop.store.ActiveRoomStore", f
 
     it("should reset various store states", function() {
       store.setStoreState({
         audioMuted: true,
         localVideoDimensions: { x: 10 },
         receivingScreenShare: true,
         remoteVideoDimensions: { y: 10 },
         screenSharingState: true,
-        videoMuted: true
+        videoMuted: true,
+        chatMessageExchanged: false
       });
 
       store.leaveRoom();
 
       expect(store._storeState.audioMuted).eql(false);
       expect(store._storeState.localVideoDimensions).eql({});
       expect(store._storeState.receivingScreenShare).eql(false);
       expect(store._storeState.remoteVideoDimensions).eql({});
       expect(store._storeState.screenSharingState).eql(SCREEN_SHARE_STATES.INACTIVE);
       expect(store._storeState.videoMuted).eql(false);
+      expect(store._storeState.chatMessageExchanged).eql(false);
     });
 
     it("should not reset the room context", function() {
       store.setStoreState({
         roomContextUrls: [{ fake: 1 }],
         roomName: "fred"
       });
 
@@ -1594,16 +1598,83 @@ describe("loop.store.ActiveRoomStore", f
 
     it("should call respective mozLoop methods", function() {
       store._handleSocialShareUpdate();
 
       sinon.assert.calledOnce(fakeMozLoop.getSocialShareProviders);
     });
   });
 
+  describe("#_handleTextChatMessage", function() {
+    beforeEach(function() {
+      store._isDesktop = true;
+      store.setupWindowData(new sharedActions.SetupWindowData({
+        windowId: "42",
+        type: "room",
+        roomToken: "fakeToken"
+      }));
+    });
+
+    function assertWeDidNothing() {
+      expect(dispatcher._eventData.receivedTextChatMessage.length).eql(1);
+      expect(dispatcher._eventData.sendTextChatMessage.length).eql(1);
+      expect(store.getStoreState().chatMessageExchanged).eql(false);
+      sinon.assert.notCalled(fakeMozLoop.telemetryAddValue);
+    }
+
+    it("should not do anything for the link clicker side", function() {
+      store._isDesktop = false;
+
+      store._handleTextChatMessage(new sharedActions.SendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!",
+        sentTimestamp: "1970-01-01T00:00:00.000Z"
+      }));
+
+      assertWeDidNothing();
+    });
+
+    it("should not do anything when a chat message has arrived before", function() {
+      store.setStoreState({ chatMessageExchanged: true });
+
+      store._handleTextChatMessage(new sharedActions.ReceivedTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!",
+        receivedTimestamp: "1970-01-01T00:00:00.000Z"
+      }));
+
+      sinon.assert.notCalled(fakeMozLoop.telemetryAddValue);
+    });
+
+    it("should not do anything for non-chat messages", function() {
+      store._handleTextChatMessage(new sharedActions.SendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.CONTEXT,
+        message: "Hello!",
+        sentTimestamp: "1970-01-01T00:00:00.000Z"
+      }));
+
+      assertWeDidNothing();
+    });
+
+    it("should ping telemetry when a chat message arrived or is to be sent", function() {
+      store._handleTextChatMessage(new sharedActions.ReceivedTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!",
+        receivedTimestamp: "1970-01-01T00:00:00.000Z"
+      }));
+
+      sinon.assert.calledOnce(fakeMozLoop.telemetryAddValue);
+      sinon.assert.calledWithExactly(fakeMozLoop.telemetryAddValue,
+        "LOOP_ROOM_SESSION_WITHCHAT", 1);
+      expect(store.getStoreState().chatMessageExchanged).eql(true);
+      expect(dispatcher._eventData.hasOwnProperty("receivedTextChatMessage")).eql(false);
+      expect(dispatcher._eventData.hasOwnProperty("sendTextChatMessage")).eql(false);
+    });
+  });
+
   describe("Events", function() {
     describe("update:{roomToken}", function() {
       beforeEach(function() {
         store.setupRoomInfo(new sharedActions.SetupRoomInfo({
           roomName: "Its a room",
           roomToken: "fakeToken",
           roomUrl: "http://invalid",
           socialShareProviders: []
--- a/browser/components/loop/test/shared/dispatcher_test.js
+++ b/browser/components/loop/test/shared/dispatcher_test.js
@@ -18,31 +18,58 @@ describe("loop.Dispatcher", function () 
   });
 
   describe("#register", function() {
     it("should register a store against an action name", function() {
       var object = { fake: true };
 
       dispatcher.register(object, ["getWindowData"]);
 
+      // XXXmikedeboer: Consider changing these tests to not access private
+      //                properties anymore (`_eventData`).
       expect(dispatcher._eventData.getWindowData[0]).eql(object);
     });
 
     it("should register multiple store against an action name", function() {
       var object1 = { fake: true };
       var object2 = { fake2: true };
 
       dispatcher.register(object1, ["getWindowData"]);
       dispatcher.register(object2, ["getWindowData"]);
 
       expect(dispatcher._eventData.getWindowData[0]).eql(object1);
       expect(dispatcher._eventData.getWindowData[1]).eql(object2);
     });
   });
 
+  describe("#unregister", function() {
+    it("should unregister a store against an action name", function() {
+      var object = { fake: true };
+
+      dispatcher.register(object, ["getWindowData"]);
+      dispatcher.unregister(object, ["getWindowData"]);
+
+      expect(dispatcher._eventData.hasOwnProperty("getWindowData")).eql(false);
+    });
+
+    it("should unregister multiple stores against an action name", function() {
+      var object1 = { fake: true };
+      var object2 = { fake2: true };
+
+      dispatcher.register(object1, ["getWindowData"]);
+      dispatcher.register(object2, ["getWindowData"]);
+
+      dispatcher.unregister(object1, ["getWindowData"]);
+      expect(dispatcher._eventData.getWindowData.length).eql(1);
+
+      dispatcher.unregister(object2, ["getWindowData"]);
+      expect(dispatcher._eventData.hasOwnProperty("getWindowData")).eql(false);
+    });
+  });
+
   describe("#dispatch", function() {
     var getDataStore1, getDataStore2, cancelStore1, connectStore1;
     var getDataAction, cancelAction, connectAction, resolveCancelStore1;
 
     beforeEach(function() {
       getDataAction = new sharedActions.GetWindowData({
         windowId: "42"
       });
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -4,17 +4,17 @@
 describe("loop.OTSdkDriver", function () {
   "use strict";
 
   var expect = chai.expect;
   var sharedActions = loop.shared.actions;
   var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var STREAM_PROPERTIES = loop.shared.utils.STREAM_PROPERTIES;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
 
   var sandbox;
   var dispatcher, driver, mozLoop, publisher, sdk, session, sessionData, subscriber;
   var publisherConfig, fakeEvent;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
--- a/browser/components/loop/test/shared/textChatStore_test.js
+++ b/browser/components/loop/test/shared/textChatStore_test.js
@@ -2,17 +2,17 @@
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 describe("loop.store.TextChatStore", function () {
   "use strict";
 
   var expect = chai.expect;
   var sharedActions = loop.shared.actions;
   var CHAT_MESSAGE_TYPES = loop.store.CHAT_MESSAGE_TYPES;
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
 
   var dispatcher, fakeSdkDriver, sandbox, store;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     sandbox.useFakeTimers();
 
     dispatcher = new loop.Dispatcher();
--- a/browser/components/loop/test/shared/textChatView_test.js
+++ b/browser/components/loop/test/shared/textChatView_test.js
@@ -4,17 +4,17 @@
 describe("loop.shared.views.TextChatView", function () {
   "use strict";
 
   var expect = chai.expect;
   var sharedActions = loop.shared.actions;
   var sharedViews = loop.shared.views;
   var TestUtils = React.addons.TestUtils;
   var CHAT_MESSAGE_TYPES = loop.store.CHAT_MESSAGE_TYPES;
-  var CHAT_CONTENT_TYPES = loop.store.CHAT_CONTENT_TYPES;
+  var CHAT_CONTENT_TYPES = loop.shared.utils.CHAT_CONTENT_TYPES;
   var fixtures = document.querySelector("#fixtures");
 
   var dispatcher, fakeSdkDriver, sandbox, store, fakeClock;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     fakeClock = sandbox.useFakeTimers();
 
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -91,17 +91,17 @@
 
   MockSDK.prototype = {
     setupStreamElements: function() {
       // Dummy function to stop warnings.
     },
 
     sendTextChatMessage: function(actionData) {
       dispatcher.dispatch(new loop.shared.actions.ReceivedTextChatMessage({
-        contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+        contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
         message: actionData.message,
         receivedTimestamp: actionData.sentTimestamp
       }));
     }
   };
 
   var mockSDK = new MockSDK();
 
@@ -402,50 +402,50 @@
       location: "http://wonderful.invalid"
       // use the fallback thumbnail
     }]
   }));
 
   textChatStore.setStoreState({textChatEnabled: true});
 
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Rheet!",
     sentTimestamp: "2015-06-23T22:21:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Hello",
     receivedTimestamp: "2015-06-23T23:24:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Nowforareallylongwordwithoutspacesorpunctuationwhichshouldcause" +
     "linewrappingissuesifthecssiswrong",
     sentTimestamp: "2015-06-23T22:23:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Check out this menu from DNA Pizza:" +
     " http://example.com/DNA/pizza/menu/lots-of-different-kinds-of-pizza/" +
     "%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%",
     sentTimestamp: "2015-06-23T22:23:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "That avocado monkey-brains pie sounds tasty!",
     receivedTimestamp: "2015-06-23T22:25:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "What time should we meet?",
     sentTimestamp: "2015-06-23T22:27:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "8:00 PM",
     receivedTimestamp: "2015-06-23T22:27:45.590Z"
   }));
 
   loop.store.StoreMixin.register({
     activeRoomStore: activeRoomStore,
     conversationStore: conversationStores[0],
     textChatStore: textChatStore
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -91,17 +91,17 @@
 
   MockSDK.prototype = {
     setupStreamElements: function() {
       // Dummy function to stop warnings.
     },
 
     sendTextChatMessage: function(actionData) {
       dispatcher.dispatch(new loop.shared.actions.ReceivedTextChatMessage({
-        contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+        contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
         message: actionData.message,
         receivedTimestamp: actionData.sentTimestamp
       }));
     }
   };
 
   var mockSDK = new MockSDK();
 
@@ -402,50 +402,50 @@
       location: "http://wonderful.invalid"
       // use the fallback thumbnail
     }]
   }));
 
   textChatStore.setStoreState({textChatEnabled: true});
 
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Rheet!",
     sentTimestamp: "2015-06-23T22:21:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Hello",
     receivedTimestamp: "2015-06-23T23:24:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Nowforareallylongwordwithoutspacesorpunctuationwhichshouldcause" +
     "linewrappingissuesifthecssiswrong",
     sentTimestamp: "2015-06-23T22:23:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "Check out this menu from DNA Pizza:" +
     " http://example.com/DNA/pizza/menu/lots-of-different-kinds-of-pizza/" +
     "%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%",
     sentTimestamp: "2015-06-23T22:23:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "That avocado monkey-brains pie sounds tasty!",
     receivedTimestamp: "2015-06-23T22:25:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "What time should we meet?",
     sentTimestamp: "2015-06-23T22:27:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
-    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    contentType: loop.shared.utils.CHAT_CONTENT_TYPES.TEXT,
     message: "8:00 PM",
     receivedTimestamp: "2015-06-23T22:27:45.590Z"
   }));
 
   loop.store.StoreMixin.register({
     activeRoomStore: activeRoomStore,
     conversationStore: conversationStores[0],
     textChatStore: textChatStore
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -8215,16 +8215,23 @@
   },
   "LOOP_ROOM_CONTEXT_CLICK": {
     "alert_emails": ["firefox-dev@mozilla.org", "mdeboer@mozilla.com"],
     "expires_in_version": "45",
     "kind": "count",
     "releaseChannelCollection": "opt-out",
     "description": "Number times room context is clicked to visit the attached URL"
   },
+  "LOOP_ROOM_SESSION_WITHCHAT": {
+    "alert_emails": ["firefox-dev@mozilla.org", "mdeboer@mozilla.com"],
+    "expires_in_version": "45",
+    "kind": "count",
+    "releaseChannelCollection": "opt-out",
+    "description": "Number of sessions where at least one chat message was exchanged"
+  },
   "E10S_AUTOSTART": {
     "expires_in_version": "never",
     "kind": "boolean",
     "description": "Whether a session is set to autostart e10s windows"
   },
   "E10S_AUTOSTART_STATUS": {
     "expires_in_version": "never",
     "kind": "enumerated",