Bug 1168841 - Style text chat elements and add timestamps. r=Standard8
authorAndrei Oprea <andrei.br92@gmail.com>
Thu, 25 Jun 2015 15:45:01 -0700
changeset 280996 dd1fe44cfbecce75c7b297c07a05af92d2e78145
parent 280995 b026f257192ee735207cdc588b1fd69e853b9305
child 280997 c73a81f1802b8ca4d80784b15b8a7265456122c8
push id4932
push userjlund@mozilla.com
push dateMon, 10 Aug 2015 18:23:06 +0000
treeherdermozilla-beta@6dd5a4f5f745 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs1168841
milestone41.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 1168841 - Style text chat elements and add timestamps. r=Standard8
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/content/shared/img/chatbubble-arrow-left.svg
browser/components/loop/content/shared/img/chatbubble-arrow-right.svg
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/otSdkDriver.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/jar.mn
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/fake-l10n.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -1471,72 +1471,226 @@ html[dir="rtl"] .standalone .room-conver
   display: flex;
   flex-flow: column nowrap;
 }
 
 .fx-embedded .text-chat-entries {
   flex: 1 1 auto;
   max-height: 120px;
   min-height: 60px;
-  padding: .7em .5em 0;
 }
 
 .text-chat-box {
   flex: 0 0 auto;
   max-height: 40px;
   min-height: 40px;
   width: 100%;
 }
 
 .text-chat-entries {
   overflow: auto;
 }
 
 .text-chat-entry {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: .5em;
   text-align: end;
-  margin-bottom: 1.5em;
+  flex-wrap: nowrap;
+  justify-content: flex-start;
+  align-content: stretch;
+  align-items: flex-start;
 }
 
 .text-chat-entry > p {
-  border-width: 1px;
-  border-style: solid;
-  border-color: #0095dd;
-  border-radius: 10000px;
-  padding: .5em 1em;
+  position: relative;
+  z-index: 10;
   /* Drop the default margins from the 'p' element. */
   margin: 0;
-  /* inline-block stops the elements taking 100% of the text-chat-view width */
-  display: inline-block;
-  /* Split really long strings with no spaces appropriately, whilst limiting the
-     width to 100%. */
-  max-width: 100%;
+  padding: .7em;
+  /* leave some room for the chat bubble arrow */
+  max-width: 80%;
+  border-width: 1px;
+  border-style: solid;
+  border-color: #2ea4ff;
+  background: #fff;
+  word-wrap: break-word;
   word-wrap: break-word;
+  flex: 0 1 auto;
+  align-self: auto;
+}
+
+.text-chat-entry.sent > p,
+.text-chat-entry.received > p {
+  background: #fff;
+}
+
+.text-chat-entry.sent > p {
+  border-radius: 15px;
+  border-bottom-right-radius: 0;
+}
+
+.text-chat-entry.received > p {
+  border-radius: 15px;
+  border-top-left-radius: 0;
+}
+
+html[dir="rtl"] .text-chat-entry.sent > p {
+  border-radius: 15px;
+  border-bottom-left-radius: 0;
+}
+
+html[dir="rtl"] .text-chat-entry.received > p {
+  border-radius: 15px;
+  border-top-right-radius: 0;
+}
+
+.text-chat-entry.received > p {
+  order: 1;
+}
+
+.text-chat-entry.sent > p {
+  order: 1;
 }
 
 .text-chat-entry.received {
   text-align: start;
 }
 
 .text-chat-entry.received > p {
   border-color: #d8d8d8;
 }
 
-.text-chat-entry.special > p {
-  border: none;
+/* Text chat entry timestamp */
+.text-chat-entry-timestamp {
+  margin: 0 .2em;
+  color: #aaa;
+  font-style: italic;
+  font-size: .8em;
+  order: 0;
+  flex: 0 1 auto;
+  align-self: center;
+}
+
+/* Sent text chat entries should be on the right */
+.text-chat-entry.sent {
+  justify-content: flex-end;
+}
+
+.received > .text-chat-entry-timestamp {
+  order: 2;
+}
+
+.sent > .text-chat-entry-timestamp {
+  order: 0;
+}
+
+/* Pseudo element used to cover part between chat bubble and chat arrow. */
+.text-chat-entry > p:after {
+  position: absolute;
+  background: #fff;
+  content: "";
+}
+
+.text-chat-entry.sent > p:after {
+  right: -2px;
+  bottom: 0;
+  width: 15px;
+  height: 9px;
+  border-top-left-radius: 15px;
+  border-top-right-radius: 22px;
+}
+
+.text-chat-entry.received > p:after {
+  top: 0;
+  left: -2px;
+  width: 15px;
+  height: 9px;
+  border-bottom-left-radius: 22px;
+  border-bottom-right-radius: 15px;
+}
+
+html[dir="rtl"] .text-chat-entry.sent > p:after {
+  /* Reset */
+  right: auto;
+  left: -1px;
+  bottom: 0;
+  width: 15px;
+  height: 9px;
+}
+
+html[dir="rtl"] .text-chat-entry.received > p:after {
+  /* Reset */
+  left: auto;
+  top: 0;
+  right: -1px;
+  width: 15px;
+  height: 6px;
+}
+
+/* Text chat entry arrow */
+.text-chat-arrow {
+  width: 18px;
+  background-repeat: no-repeat;
+  flex: 0 1 auto;
+  position: relative;
+  z-index: 5;
+}
+
+.text-chat-entry.sent .text-chat-arrow {
+  margin-bottom: -1px;
+  margin-left: -11px;
+  height: 10px;
+  background-image: url("../img/chatbubble-arrow-right.svg");
+  order: 2;
+  align-self: flex-end;
+}
+
+.text-chat-entry.received .text-chat-arrow {
+  margin-left: 0;
+  margin-right: -9px;
+  height: 10px;
+  background-image: url("../img/chatbubble-arrow-left.svg");
+  order: 0;
+  align-self: auto;
+}
+
+html[dir="rtl"] .text-chat-arrow {
+  transform: scaleX(-1);
+}
+
+html[dir="rtl"] .text-chat-entry.sent .text-chat-arrow {
+  /* Reset margin. */
+  margin-left: 0;
+  margin-right: -11px;
+}
+
+html[dir="rtl"] .text-chat-entry.received .text-chat-arrow {
+  /* Reset margin. */
+  margin-right: 0;
+  margin-left: -10px;
 }
 
 .text-chat-entry.special.room-name {
   color: black;
   font-weight: bold;
   text-align: start;
   background-color: #E8F6FE;
   padding-bottom: 0;
   margin-bottom: 0;
 }
 
+.text-chat-entry.special.room-name p {
+  background: #E8F6FE;
+}
+
+.text-chat-entry.special > p {
+  border: none;
+}
+
 .text-chat-box {
   margin: auto;
 }
 
 .text-chat-box > form > input {
   width: 100%;
   height: 40px;
   padding: 0 .5em .5em;
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/chatbubble-arrow-left.svg
@@ -0,0 +1,14 @@
+<?xml version="1.0"?>
+<svg width="20" height="8" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
+ <title>chatbubble-arrow</title>
+ <desc>Created with Sketch.</desc>
+ <g>
+  <title>Layer 1</title>
+  <g transform="rotate(180 6.2844319343566895,3.8364052772521973) " id="svg_1" fill="none">
+   <path id="svg_2" fill="#d8d8d8" d="m12.061934,7.656905l-9.299002,0l0,-1l6.088001,0c-2.110002,-0.967001 -4.742001,-2.818 -6.088001,-6.278l0.932,-0.363c2.201999,5.664001 8.377999,6.637 8.439999,6.646c0.259001,0.039 0.444,0.27 0.426001,0.531c-0.019001,0.262 -0.237,0.464 -0.498999,0.464l-12.072001,-0.352001"/>
+  </g>
+  <line id="svg_13" y2="0.529488" x2="13.851821" y1="0.529488" x1="17.916953" stroke="#d8d8d8" fill="none"/>
+  <line id="svg_26" y2="0.529488" x2="9.79687" y1="0.529488" x1="13.862002" stroke="#d8d8d8" fill="none"/>
+  <line id="svg_27" y2="0.529488" x2="15.908413" y1="0.529488" x1="19.973545" stroke="#d8d8d8" fill="none"/>
+ </g>
+</svg>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/chatbubble-arrow-right.svg
@@ -0,0 +1,14 @@
+<?xml version="1.0"?>
+<svg width="20" height="9" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
+ <title>chatbubble-arrow</title>
+ <desc>Created with Sketch.</desc>
+ <g>
+  <title>Layer 1</title>
+  <g id="svg_1" fill="none">
+   <path id="svg_2" fill="#2EA4FF" d="m19.505243,8.972466l-9.299002,0l0,-1l6.088001,0c-2.110002,-0.967 -4.742001,-2.818 -6.088001,-6.278l0.932,-0.363c2.202,5.664 8.377999,6.637 8.44,6.646c0.259001,0.039 0.444,0.27 0.426001,0.531c-0.019001,0.262 -0.237,0.464 -0.498999,0.464l-12.072001,-0.352"/>
+  </g>
+  <line id="svg_13" y2="8.474788" x2="6.200791" y1="8.474788" x1="10.265923" stroke="#22a4ff" fill="none"/>
+  <line id="svg_26" y2="8.474788" x2="2.14584" y1="8.474788" x1="6.210972" stroke="#22a4ff" fill="none"/>
+  <line id="svg_27" y2="8.474788" x2="0.000501" y1="8.474788" x1="4.065633" stroke="#22a4ff" fill="none"/>
+ </g>
+</svg>
\ No newline at end of file
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -172,25 +172,28 @@ loop.shared.actions = (function() {
       available: Boolean
     }),
 
     /**
      * Used to send a message to the other peer.
      */
     SendTextChatMessage: Action.define("sendTextChatMessage", {
       contentType: String,
-      message: String
+      message: String,
+      sentTimestamp: String
     }),
 
     /**
      * Notifies that a message has been received from the other peer.
      */
     ReceivedTextChatMessage: Action.define("receivedTextChatMessage", {
       contentType: String,
-      message: String
+      message: String,
+      receivedTimestamp: String
+      // sentTimestamp: String (optional)
     }),
 
     /**
      * Used by the ongoing views to notify stores about the elements
      * required for the sdk.
      */
     SetupStreamElements: Action.define("setupStreamElements", {
       // The configuration for the publisher/subscribe options
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -693,18 +693,22 @@ loop.OTSdkDriver = (function() {
         if (err) {
           console.error(err);
           return;
         }
 
         channel.on({
           message: function(ev) {
             try {
+              var message = JSON.parse(ev.data);
+              /* Append the timestamp. This is the time that gets shown. */
+              message.receivedTimestamp = (new Date()).toISOString();
+
               this.dispatcher.dispatch(
-                new sharedActions.ReceivedTextChatMessage(JSON.parse(ev.data)));
+                new sharedActions.ReceivedTextChatMessage(message));
             } catch (ex) {
               console.error("Failed to process incoming chat message", ex);
             }
           }.bind(this),
 
           close: function(e) {
             // XXX We probably want to dispatch and handle this somehow.
             console.log("Subscribed data channel closed!");
--- a/browser/components/loop/content/shared/js/textChatStore.js
+++ b/browser/components/loop/content/shared/js/textChatStore.js
@@ -91,17 +91,19 @@ loop.store.TextChatStore = (function() {
      */
     _appendTextChatMessage: function(type, messageData) {
       // We create a new list to avoid updating the store's state directly,
       // which confuses the views.
       var message = {
         type: type,
         contentType: messageData.contentType,
         message: messageData.message,
-        extraData: messageData.extraData
+        extraData: messageData.extraData,
+        sentTimestamp: messageData.sentTimestamp,
+        receivedTimestamp: messageData.receivedTimestamp
       };
       var newList = this._storeState.messageList.concat(message);
       this.setStoreState({ messageList: newList });
 
       // Notify MozLoopService if appropriate that a message has been appended
       // and it should therefore check if we need a different sized window or not.
       if (type != CHAT_MESSAGE_TYPES.SPECIAL) {
         window.dispatchEvent(new CustomEvent("LoopChatMessageAppended"));
--- a/browser/components/loop/content/shared/js/textChatView.js
+++ b/browser/components/loop/content/shared/js/textChatView.js
@@ -16,30 +16,54 @@ loop.shared.views.chat = (function(mozL1
    * Renders an individual entry for the text chat entries view.
    */
   var TextChatEntry = React.createClass({displayName: "TextChatEntry",
     mixins: [React.addons.PureRenderMixin],
 
     propTypes: {
       contentType: React.PropTypes.string.isRequired,
       message: React.PropTypes.string.isRequired,
+      showTimestamp: React.PropTypes.bool.isRequired,
+      timestamp: React.PropTypes.string.isRequired,
       type: React.PropTypes.string.isRequired
     },
 
+    /**
+     * Pretty print timestamp. From time in milliseconds to HH:MM
+     * (or L10N equivalent).
+     *
+     */
+    _renderTimestamp: function() {
+      var date = new Date(this.props.timestamp);
+      var language = mozL10n.language ? mozL10n.language.code
+                                      : mozL10n.getLanguage();
+
+      return (
+        React.createElement("span", {className: "text-chat-entry-timestamp"}, 
+          date.toLocaleTimeString(language,
+                                   {hour: "numeric", minute: "numeric",
+                                   hour12: false})
+        )
+      );
+    },
+
     render: function() {
       var classes = React.addons.classSet({
         "text-chat-entry": true,
         "received": this.props.type === CHAT_MESSAGE_TYPES.RECEIVED,
+        "sent": this.props.type === CHAT_MESSAGE_TYPES.SENT,
         "special": this.props.type === CHAT_MESSAGE_TYPES.SPECIAL,
         "room-name": this.props.contentType === CHAT_CONTENT_TYPES.ROOM_NAME
       });
 
       return (
         React.createElement("div", {className: classes}, 
-          React.createElement("p", null, this.props.message)
+          React.createElement("p", null, this.props.message), 
+          React.createElement("span", {className: "text-chat-arrow"}), 
+          this.props.showTimestamp ? this._renderTimestamp() : null
         )
       );
     }
   });
 
   var TextChatRoomName = React.createClass({displayName: "TextChatRoomName",
     mixins: [React.addons.PureRenderMixin],
 
@@ -62,16 +86,20 @@ loop.shared.views.chat = (function(mozL1
    * component only updates when the message list is changed.
    */
   var TextChatEntriesView = React.createClass({displayName: "TextChatEntriesView",
     mixins: [
       React.addons.PureRenderMixin,
       sharedMixins.AudioMixin
     ],
 
+    statics: {
+      ONE_MINUTE: 60
+    },
+
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       messageList: React.PropTypes.array.isRequired
     },
 
     getInitialState: function() {
       return {
         receivedMessageCount: 0
@@ -109,16 +137,19 @@ loop.shared.views.chat = (function(mozL1
           } catch (ex) {
             console.error("TextChatEntriesView.componentDidUpdate exception", ex);
           }
         }.bind(this));
       }
     },
 
     render: function() {
+      /* Keep track of the last printed timestamp. */
+      var lastTimestamp = 0;
+
       if (!this.props.messageList.length) {
         return null;
       }
 
       return (
         React.createElement("div", {className: "text-chat-entries"}, 
           React.createElement("div", {className: "text-chat-scroller"}, 
             
@@ -136,33 +167,90 @@ loop.shared.views.chat = (function(mozL1
                             dispatcher: this.props.dispatcher, 
                             showContextTitle: true, 
                             thumbnail: entry.extraData.thumbnail, 
                             url: entry.extraData.location, 
                             useDesktopPaths: false})
                         )
                       );
                     default:
-                      console.error("Unsupported contentType", entry.contentType);
+                      console.error("Unsupported contentType",
+                                    entry.contentType);
                       return null;
                   }
                 }
 
+                /* For SENT messages there is no received timestamp. */
+                var timestamp = entry.receivedTimestamp || entry.sentTimestamp;
+
+                var timeDiff = this._isOneMinDelta(timestamp, lastTimestamp);
+                var shouldShowTimestamp = this._shouldShowTimestamp(i,
+                                                                    timeDiff);
+
+                if (shouldShowTimestamp) {
+                  lastTimestamp = timestamp;
+                }
+
                 return (
-                  React.createElement(TextChatEntry, {
-                    contentType: entry.contentType, 
-                    key: i, 
-                    message: entry.message, 
-                    type: entry.type})
-                );
+                  React.createElement(TextChatEntry, {contentType: entry.contentType, 
+                                 key: i, 
+                                 message: entry.message, 
+                                 showTimestamp: shouldShowTimestamp, 
+                                 timestamp: timestamp, 
+                                 type: entry.type})
+                  );
               }, this)
             
           )
         )
       );
+    },
+
+    /**
+     * Decide to show timestamp or not on a message.
+     * If the time difference between two consecutive messages is bigger than
+     * one minute or if message types are different.
+     *
+     * @param {number} idx       Index of message in the messageList.
+     * @param {boolean} timeDiff If difference between consecutive messages is
+     *                           bigger than one minute.
+     */
+    _shouldShowTimestamp: function(idx, timeDiff) {
+      if (!idx) {
+        return true;
+      }
+
+      /* If consecutive messages are from different senders */
+      if (this.props.messageList[idx].type !==
+          this.props.messageList[idx - 1].type) {
+        return true;
+      }
+
+      return timeDiff;
+    },
+
+    /**
+     * Determines if difference between the two timestamp arguments
+     * is bigger that 60 (1 minute)
+     *
+     * Timestamps are using ISO8601 format.
+     *
+     * @param {string} currTime Timestamp of message yet to be rendered.
+     * @param {string} prevTime Last timestamp printed in the chat view.
+     */
+    _isOneMinDelta: function(currTime, prevTime) {
+      var date1 = new Date(currTime);
+      var date2 = new Date(prevTime);
+      var delta = date1 - date2;
+
+      if (delta / 1000 >= this.constructor.ONE_MINUTE) {
+        return true;
+      }
+
+      return false;
     }
   });
 
   /**
    * Displays a text chat entry input box for sending messages.
    *
    * @property {loop.Dispatcher} dispatcher
    * @property {Boolean} showPlaceholder    Set to true to show the placeholder message.
@@ -209,17 +297,18 @@ loop.shared.views.chat = (function(mozL1
 
       // Don't send empty messages.
       if (!this.state.messageDetail) {
         return;
       }
 
       this.props.dispatcher.dispatch(new sharedActions.SendTextChatMessage({
         contentType: CHAT_CONTENT_TYPES.TEXT,
-        message: this.state.messageDetail
+        message: this.state.messageDetail,
+        sentTimestamp: (new Date()).toISOString()
       }));
 
       // Reset the form to empty, ready for the next message.
       this.setState({ messageDetail: "" });
     },
 
     render: function() {
       if (!this.props.textChatEnabled) {
@@ -304,11 +393,12 @@ loop.shared.views.chat = (function(mozL1
             textChatEnabled: this.state.textChatEnabled})
         )
       );
     }
   });
 
   return {
     TextChatEntriesView: TextChatEntriesView,
+    TextChatEntry: TextChatEntry,
     TextChatView: TextChatView
   };
 })(navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/content/shared/js/textChatView.jsx
+++ b/browser/components/loop/content/shared/js/textChatView.jsx
@@ -16,30 +16,54 @@ loop.shared.views.chat = (function(mozL1
    * Renders an individual entry for the text chat entries view.
    */
   var TextChatEntry = React.createClass({
     mixins: [React.addons.PureRenderMixin],
 
     propTypes: {
       contentType: React.PropTypes.string.isRequired,
       message: React.PropTypes.string.isRequired,
+      showTimestamp: React.PropTypes.bool.isRequired,
+      timestamp: React.PropTypes.string.isRequired,
       type: React.PropTypes.string.isRequired
     },
 
+    /**
+     * Pretty print timestamp. From time in milliseconds to HH:MM
+     * (or L10N equivalent).
+     *
+     */
+    _renderTimestamp: function() {
+      var date = new Date(this.props.timestamp);
+      var language = mozL10n.language ? mozL10n.language.code
+                                      : mozL10n.getLanguage();
+
+      return (
+        <span className="text-chat-entry-timestamp">
+          {date.toLocaleTimeString(language,
+                                   {hour: "numeric", minute: "numeric",
+                                   hour12: false})}
+        </span>
+      );
+    },
+
     render: function() {
       var classes = React.addons.classSet({
         "text-chat-entry": true,
         "received": this.props.type === CHAT_MESSAGE_TYPES.RECEIVED,
+        "sent": this.props.type === CHAT_MESSAGE_TYPES.SENT,
         "special": this.props.type === CHAT_MESSAGE_TYPES.SPECIAL,
         "room-name": this.props.contentType === CHAT_CONTENT_TYPES.ROOM_NAME
       });
 
       return (
         <div className={classes}>
           <p>{this.props.message}</p>
+          <span className="text-chat-arrow" />
+          {this.props.showTimestamp ? this._renderTimestamp() : null}
         </div>
       );
     }
   });
 
   var TextChatRoomName = React.createClass({
     mixins: [React.addons.PureRenderMixin],
 
@@ -62,16 +86,20 @@ loop.shared.views.chat = (function(mozL1
    * component only updates when the message list is changed.
    */
   var TextChatEntriesView = React.createClass({
     mixins: [
       React.addons.PureRenderMixin,
       sharedMixins.AudioMixin
     ],
 
+    statics: {
+      ONE_MINUTE: 60
+    },
+
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       messageList: React.PropTypes.array.isRequired
     },
 
     getInitialState: function() {
       return {
         receivedMessageCount: 0
@@ -109,16 +137,19 @@ loop.shared.views.chat = (function(mozL1
           } catch (ex) {
             console.error("TextChatEntriesView.componentDidUpdate exception", ex);
           }
         }.bind(this));
       }
     },
 
     render: function() {
+      /* Keep track of the last printed timestamp. */
+      var lastTimestamp = 0;
+
       if (!this.props.messageList.length) {
         return null;
       }
 
       return (
         <div className="text-chat-entries">
           <div className="text-chat-scroller">
             {
@@ -136,33 +167,90 @@ loop.shared.views.chat = (function(mozL1
                             dispatcher={this.props.dispatcher}
                             showContextTitle={true}
                             thumbnail={entry.extraData.thumbnail}
                             url={entry.extraData.location}
                             useDesktopPaths={false} />
                         </div>
                       );
                     default:
-                      console.error("Unsupported contentType", entry.contentType);
+                      console.error("Unsupported contentType",
+                                    entry.contentType);
                       return null;
                   }
                 }
 
+                /* For SENT messages there is no received timestamp. */
+                var timestamp = entry.receivedTimestamp || entry.sentTimestamp;
+
+                var timeDiff = this._isOneMinDelta(timestamp, lastTimestamp);
+                var shouldShowTimestamp = this._shouldShowTimestamp(i,
+                                                                    timeDiff);
+
+                if (shouldShowTimestamp) {
+                  lastTimestamp = timestamp;
+                }
+
                 return (
-                  <TextChatEntry
-                    contentType={entry.contentType}
-                    key={i}
-                    message={entry.message}
-                    type={entry.type} />
-                );
+                  <TextChatEntry contentType={entry.contentType}
+                                 key={i}
+                                 message={entry.message}
+                                 showTimestamp={shouldShowTimestamp}
+                                 timestamp={timestamp}
+                                 type={entry.type} />
+                  );
               }, this)
             }
           </div>
         </div>
       );
+    },
+
+    /**
+     * Decide to show timestamp or not on a message.
+     * If the time difference between two consecutive messages is bigger than
+     * one minute or if message types are different.
+     *
+     * @param {number} idx       Index of message in the messageList.
+     * @param {boolean} timeDiff If difference between consecutive messages is
+     *                           bigger than one minute.
+     */
+    _shouldShowTimestamp: function(idx, timeDiff) {
+      if (!idx) {
+        return true;
+      }
+
+      /* If consecutive messages are from different senders */
+      if (this.props.messageList[idx].type !==
+          this.props.messageList[idx - 1].type) {
+        return true;
+      }
+
+      return timeDiff;
+    },
+
+    /**
+     * Determines if difference between the two timestamp arguments
+     * is bigger that 60 (1 minute)
+     *
+     * Timestamps are using ISO8601 format.
+     *
+     * @param {string} currTime Timestamp of message yet to be rendered.
+     * @param {string} prevTime Last timestamp printed in the chat view.
+     */
+    _isOneMinDelta: function(currTime, prevTime) {
+      var date1 = new Date(currTime);
+      var date2 = new Date(prevTime);
+      var delta = date1 - date2;
+
+      if (delta / 1000 >= this.constructor.ONE_MINUTE) {
+        return true;
+      }
+
+      return false;
     }
   });
 
   /**
    * Displays a text chat entry input box for sending messages.
    *
    * @property {loop.Dispatcher} dispatcher
    * @property {Boolean} showPlaceholder    Set to true to show the placeholder message.
@@ -209,17 +297,18 @@ loop.shared.views.chat = (function(mozL1
 
       // Don't send empty messages.
       if (!this.state.messageDetail) {
         return;
       }
 
       this.props.dispatcher.dispatch(new sharedActions.SendTextChatMessage({
         contentType: CHAT_CONTENT_TYPES.TEXT,
-        message: this.state.messageDetail
+        message: this.state.messageDetail,
+        sentTimestamp: (new Date()).toISOString()
       }));
 
       // Reset the form to empty, ready for the next message.
       this.setState({ messageDetail: "" });
     },
 
     render: function() {
       if (!this.props.textChatEnabled) {
@@ -304,11 +393,12 @@ loop.shared.views.chat = (function(mozL1
             textChatEnabled={this.state.textChatEnabled} />
         </div>
       );
     }
   });
 
   return {
     TextChatEntriesView: TextChatEntriesView,
+    TextChatEntry: TextChatEntry,
     TextChatView: TextChatView
   };
 })(navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -35,16 +35,18 @@ browser.jar:
   content/browser/loop/shared/img/sad.png                       (content/shared/img/sad.png)
   content/browser/loop/shared/img/icon_32.png                   (content/shared/img/icon_32.png)
   content/browser/loop/shared/img/icon_64.png                   (content/shared/img/icon_64.png)
   content/browser/loop/shared/img/spinner.svg                   (content/shared/img/spinner.svg)
   # XXX could get rid of the png spinner usages and replace them with the svg
   # one?
   content/browser/loop/shared/img/spinner.png                   (content/shared/img/spinner.png)
   content/browser/loop/shared/img/spinner@2x.png                (content/shared/img/spinner@2x.png)
+  content/browser/loop/shared/img/chatbubble-arrow-left.svg     (content/shared/img/chatbubble-arrow-left.svg)
+  content/browser/loop/shared/img/chatbubble-arrow-right.svg    (content/shared/img/chatbubble-arrow-right.svg)
   content/browser/loop/shared/img/audio-inverse-14x14.png       (content/shared/img/audio-inverse-14x14.png)
   content/browser/loop/shared/img/audio-inverse-14x14@2x.png    (content/shared/img/audio-inverse-14x14@2x.png)
   content/browser/loop/shared/img/facemute-14x14.png            (content/shared/img/facemute-14x14.png)
   content/browser/loop/shared/img/facemute-14x14@2x.png         (content/shared/img/facemute-14x14@2x.png)
   content/browser/loop/shared/img/hangup-inverse-14x14.png      (content/shared/img/hangup-inverse-14x14.png)
   content/browser/loop/shared/img/hangup-inverse-14x14@2x.png   (content/shared/img/hangup-inverse-14x14@2x.png)
   content/browser/loop/shared/img/mute-inverse-14x14.png        (content/shared/img/mute-inverse-14x14.png)
   content/browser/loop/shared/img/mute-inverse-14x14@2x.png     (content/shared/img/mute-inverse-14x14@2x.png)
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -1336,32 +1336,39 @@ describe("loop.OTSdkDriver", function ()
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.DataChannelsAvailable({
             available: true
           }));
       });
 
       it("should dispatch `ReceivedTextChatMessage` when a text message is received", function() {
         var fakeChannel = _.extend({}, Backbone.Events);
+        var data = '{"contentType":"' + CHAT_CONTENT_TYPES.TEXT +
+                   '","message":"Are you there?","receivedTimestamp": "2015-06-25T00:29:14.197Z"}';
+        var clock = sinon.useFakeTimers();
 
         subscriber._.getDataChannel.callsArgWith(2, null, fakeChannel);
 
         session.trigger("signal:readyForDataChannel");
 
         // Now send the message.
         fakeChannel.trigger("message", {
-          data: '{"contentType":"' + CHAT_CONTENT_TYPES.TEXT + '","message":"Are you there?"}'
+          data: data
         });
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.ReceivedTextChatMessage({
             contentType: CHAT_CONTENT_TYPES.TEXT,
-            message: "Are you there?"
+            message: "Are you there?",
+            receivedTimestamp: "1970-01-01T00:00:00.000Z"
           }));
+
+        /* Restore the time. */
+        clock.restore();
       });
     });
 
     describe("exception", function() {
       describe("Unable to publish (GetUserMedia)", function() {
         it("should destroy the publisher", function() {
           sdk.trigger("exception", {
             code: OT.ExceptionCodes.UNABLE_TO_PUBLISH,
--- a/browser/components/loop/test/shared/textChatStore_test.js
+++ b/browser/components/loop/test/shared/textChatStore_test.js
@@ -65,24 +65,29 @@ describe("loop.store.TextChatStore", fun
   });
 
   describe("#receivedTextChatMessage", function() {
     it("should add the message to the list", function() {
       var message = "Hello!";
 
       store.receivedTextChatMessage({
         contentType: CHAT_CONTENT_TYPES.TEXT,
-        message: message
+        message: message,
+        extraData: undefined,
+        sentTimestamp: "2015-06-24T23:58:53.848Z",
+        receivedTimestamp: "1970-01-01T00:00:00.000Z"
       });
 
       expect(store.getStoreState("messageList")).eql([{
         type: CHAT_MESSAGE_TYPES.RECEIVED,
         contentType: CHAT_CONTENT_TYPES.TEXT,
         message: message,
-        extraData: undefined
+        extraData: undefined,
+        sentTimestamp: "2015-06-24T23:58:53.848Z",
+        receivedTimestamp: "1970-01-01T00:00:00.000Z"
       }]);
     });
 
     it("should not add messages for unknown content types", function() {
       store.receivedTextChatMessage({
         contentType: "invalid type",
         message: "Hi"
       });
@@ -113,26 +118,30 @@ describe("loop.store.TextChatStore", fun
 
       sinon.assert.calledOnce(fakeSdkDriver.sendTextChatMessage);
       sinon.assert.calledWithExactly(fakeSdkDriver.sendTextChatMessage, messageData);
     });
 
     it("should add the message to the list", function() {
       var messageData = {
         contentType: CHAT_CONTENT_TYPES.TEXT,
-        message: "It's awesome!"
+        message: "It's awesome!",
+        sentTimestamp: "2015-06-24T23:58:53.848Z",
+        receivedTimestamp: "2015-06-24T23:58:53.848Z"
       };
 
       store.sendTextChatMessage(messageData);
 
       expect(store.getStoreState("messageList")).eql([{
         type: CHAT_MESSAGE_TYPES.SENT,
         contentType: messageData.contentType,
         message: messageData.message,
-        extraData: undefined
+        extraData: undefined,
+        sentTimestamp: "2015-06-24T23:58:53.848Z",
+        receivedTimestamp: "2015-06-24T23:58:53.848Z"
       }]);
     });
 
     it("should dipatch a LoopChatMessageAppended event", function() {
       store.sendTextChatMessage({
         contentType: CHAT_CONTENT_TYPES.TEXT,
         message: "Hello!"
       });
@@ -150,17 +159,19 @@ describe("loop.store.TextChatStore", fun
         roomOwner: "Mark",
         roomUrl: "fake"
       }));
 
       expect(store.getStoreState("messageList")).eql([{
         type: CHAT_MESSAGE_TYPES.SPECIAL,
         contentType: CHAT_CONTENT_TYPES.ROOM_NAME,
         message: "Let's share!",
-        extraData: undefined
+        extraData: undefined,
+        sentTimestamp: undefined,
+        receivedTimestamp: undefined
       }]);
     });
 
     it("should add the context to the list", function() {
       store.updateRoomInfo(new sharedActions.UpdateRoomInfo({
         roomName: "Let's share!",
         roomOwner: "Mark",
         roomUrl: "fake",
@@ -171,21 +182,25 @@ describe("loop.store.TextChatStore", fun
         }]
       }));
 
       expect(store.getStoreState("messageList")).eql([
         {
           type: CHAT_MESSAGE_TYPES.SPECIAL,
           contentType: CHAT_CONTENT_TYPES.ROOM_NAME,
           message: "Let's share!",
-          extraData: undefined
+          extraData: undefined,
+          sentTimestamp: undefined,
+          receivedTimestamp: undefined
         }, {
           type: CHAT_MESSAGE_TYPES.SPECIAL,
           contentType: CHAT_CONTENT_TYPES.CONTEXT,
           message: "A wonderful event",
+          sentTimestamp: undefined,
+          receivedTimestamp: undefined,
           extraData: {
             location: "http://wonderful.invalid",
             thumbnail: "fake"
           }
         }
       ]);
     });
 
--- a/browser/components/loop/test/shared/textChatView_test.js
+++ b/browser/components/loop/test/shared/textChatView_test.js
@@ -6,21 +6,21 @@ describe("loop.shared.views.TextChatView
 
   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 dispatcher, fakeSdkDriver, sandbox, store;
+  var dispatcher, fakeSdkDriver, sandbox, store, fakeClock;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
-    sandbox.useFakeTimers();
+    fakeClock = sandbox.useFakeTimers();
 
     dispatcher = new loop.Dispatcher();
     sandbox.stub(dispatcher, "dispatch");
 
     fakeSdkDriver = {
       sendTextChatMessage: sinon.stub()
     };
 
@@ -115,31 +115,184 @@ describe("loop.shared.views.TextChatView
           message: "Hello!"
         }]
       });
 
       sinon.assert.notCalled(view.play);
     });
   });
 
+  describe("TextChatEntry", function() {
+    var view;
+
+    function mountTestComponent(extraProps) {
+      var props = _.extend({
+        dispatcher: dispatcher
+      }, extraProps);
+      return TestUtils.renderIntoDocument(
+        React.createElement(loop.shared.views.chat.TextChatEntry, props));
+    }
+
+    it("should not render a timestamp", function() {
+      view = mountTestComponent({
+        showTimestamp: false,
+        timestamp: "2015-06-23T22:48:39.738Z"
+      });
+      var node = view.getDOMNode();
+
+      expect(node.querySelector(".text-chat-entry-timestamp")).to.eql(null);
+    });
+
+    it("should render a timestamp", function() {
+      view = mountTestComponent({
+        showTimestamp: true,
+        timestamp: "2015-06-23T22:48:39.738Z"
+      });
+      var node = view.getDOMNode();
+
+      expect(node.querySelector(".text-chat-entry-timestamp")).to.not.eql(null);
+    });
+  });
+
+  describe("TextChatEntriesView", function() {
+    var view, node;
+
+    function mountTestComponent(extraProps) {
+      var props = _.extend({
+        dispatcher: dispatcher
+      }, extraProps);
+      return TestUtils.renderIntoDocument(
+        React.createElement(loop.shared.views.chat.TextChatEntriesView, props));
+    }
+
+    beforeEach(function() {
+      store.setStoreState({ textChatEnabled: true });
+    });
+
+    it("should show timestamps if there are different senders", function() {
+      view = mountTestComponent({
+        messageList: [{
+          type: CHAT_MESSAGE_TYPES.RECEIVED,
+          contentType: CHAT_CONTENT_TYPES.TEXT,
+          message: "Hello!"
+        }, {
+          type: CHAT_MESSAGE_TYPES.SENT,
+          contentType: CHAT_CONTENT_TYPES.TEXT,
+          message: "Is it me you're looking for?"
+        }]
+      });
+      node = view.getDOMNode();
+
+      expect(node.querySelectorAll(".text-chat-entry-timestamp").length)
+          .to.eql(2);
+    });
+
+    it("should show timestamps if they are 1 minute apart (SENT)", function() {
+      view = mountTestComponent({
+        messageList: [{
+          type: CHAT_MESSAGE_TYPES.SENT,
+          contentType: CHAT_CONTENT_TYPES.TEXT,
+          message: "Hello!",
+          sentTimestamp: "2015-06-25T17:53:55.357Z"
+        }, {
+          type: CHAT_MESSAGE_TYPES.SENT,
+          contentType: CHAT_CONTENT_TYPES.TEXT,
+          message: "Is it me you're looking for?",
+          sentTimestamp: "2015-06-25T17:54:55.357Z"
+        }]
+      });
+      node = view.getDOMNode();
+
+      expect(node.querySelectorAll(".text-chat-entry-timestamp").length)
+          .to.eql(2);
+    });
+
+    it("should show timestamps if they are 1 minute apart (RECV)", function() {
+      view = mountTestComponent({
+        messageList: [{
+          type: CHAT_MESSAGE_TYPES.RECEIVED,
+          contentType: CHAT_CONTENT_TYPES.TEXT,
+          message: "Hello!",
+          receivedTimestamp: "2015-06-25T17:53:55.357Z"
+        }, {
+          type: CHAT_MESSAGE_TYPES.RECEIVED,
+          contentType: CHAT_CONTENT_TYPES.TEXT,
+          message: "Is it me you're looking for?",
+          receivedTimestamp: "2015-06-25T17:54:55.357Z"
+        }]
+      });
+      node = view.getDOMNode();
+
+      expect(node.querySelectorAll(".text-chat-entry-timestamp").length)
+          .to.eql(2);
+    });
+
+    it("should not show timestamps from msgs sent in the same minute", function() {
+      view = mountTestComponent({
+        messageList: [{
+          type: CHAT_MESSAGE_TYPES.RECEIVED,
+          contentType: CHAT_CONTENT_TYPES.TEXT,
+          message: "Hello!"
+        }, {
+          type: CHAT_MESSAGE_TYPES.RECEIVED,
+          contentType: CHAT_CONTENT_TYPES.TEXT,
+          message: "Is it me you're looking for?"
+        }]
+      });
+      node = view.getDOMNode();
+
+      expect(node.querySelectorAll(".text-chat-entry-timestamp").length)
+          .to.eql(1);
+    });
+  });
+
   describe("TextChatView", function() {
     var view;
 
     function mountTestComponent(extraProps) {
       var props = _.extend({
         dispatcher: dispatcher
       }, extraProps);
       return TestUtils.renderIntoDocument(
         React.createElement(loop.shared.views.chat.TextChatView, props));
     }
 
     beforeEach(function() {
       store.setStoreState({ textChatEnabled: true });
     });
 
+    it("should show timestamps from msgs sent more than 1 min apart", function() {
+      view = mountTestComponent();
+
+      store.sendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!",
+        sentTimestamp: "1970-01-01T00:02:00.000Z",
+        receivedTimestamp: "1970-01-01T00:02:00.000Z"
+      });
+
+      store.sendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Is it me you're looking for?",
+        sentTimestamp: "1970-01-01T00:03:00.000Z",
+        receivedTimestamp: "1970-01-01T00:03:00.000Z"
+      });
+      store.sendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Is it me you're looking for?",
+        sentTimestamp: "1970-01-01T00:02:00.000Z",
+        receivedTimestamp: "1970-01-01T00:02:00.000Z"
+      });
+
+      var node = view.getDOMNode();
+
+      expect(node.querySelectorAll(".text-chat-entry-timestamp").length)
+          .to.eql(2);
+    });
+
     it("should not display anything if no messages and text chat not enabled and showAlways is false", function() {
       store.setStoreState({ textChatEnabled: false });
 
       view = mountTestComponent({
         showAlways: false
       });
 
       expect(view.getDOMNode()).eql(null);
@@ -167,16 +320,62 @@ describe("loop.shared.views.TextChatView
       view = mountTestComponent();
 
       var node = view.getDOMNode();
 
       expect(node.querySelector(".text-chat-box")).not.eql(null);
       expect(node.querySelector(".text-chat-entries")).eql(null);
     });
 
+    it("should render message entries when message were sent/ received", function() {
+      view = mountTestComponent();
+
+      store.receivedTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!"
+      });
+      store.sendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Is it me you're looking for?"
+      });
+
+      var node = view.getDOMNode();
+      expect(node.querySelector(".text-chat-entries")).to.not.eql(null);
+
+      var entries = node.querySelectorAll(".text-chat-entry");
+      expect(entries.length).to.eql(2);
+      expect(entries[0].classList.contains("received")).to.eql(true);
+      expect(entries[1].classList.contains("received")).to.not.eql(true);
+    });
+
+    it("should add `sent` CSS class selector to msg of type SENT", function() {
+      var node = mountTestComponent().getDOMNode();
+
+      store.sendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Foo",
+        timestamp: 0
+      });
+
+      expect(node.querySelector(".sent")).to.not.eql(null);
+    });
+
+    it("should add `received` CSS class selector to msg of type RECEIVED",
+       function() {
+         var node = mountTestComponent().getDOMNode();
+
+         store.receivedTextChatMessage({
+           contentType: CHAT_CONTENT_TYPES.TEXT,
+           message: "Foo",
+           timestamp: 0
+         });
+
+         expect(node.querySelector(".received")).to.not.eql(null);
+     });
+
     it("should render a room name special entry", function() {
       view = mountTestComponent({
         showRoomName: true
       });
 
       store.updateRoomInfo(new sharedActions.UpdateRoomInfo({
         roomName: "A wonderful surprise!",
         roomOwner: "Chris",
@@ -228,17 +427,18 @@ describe("loop.shared.views.TextChatView
         key: "Enter",
         which: 13
       });
 
       sinon.assert.calledOnce(dispatcher.dispatch);
       sinon.assert.calledWithExactly(dispatcher.dispatch,
         new sharedActions.SendTextChatMessage({
           contentType: CHAT_CONTENT_TYPES.TEXT,
-          message: "Hello!"
+          message: "Hello!",
+          sentTimestamp: "1970-01-01T00:00:00.000Z"
         }));
     });
 
     it("should not dispatch SendTextChatMessage when the message is empty", function() {
       view = mountTestComponent();
 
       var entryNode = view.getDOMNode().querySelector(".text-chat-box > form > input");
 
--- a/browser/components/loop/ui/fake-l10n.js
+++ b/browser/components/loop/ui/fake-l10n.js
@@ -17,10 +17,15 @@ navigator.mozL10n = document.mozL10n = {
 
     // upcase the first letter
     var readableStringId = stringId.replace(/^./, function(match) {
       "use strict";
       return match.toUpperCase();
     }).replace(/_/g, " ");  // and convert _ chars to spaces
 
     return "" + readableStringId + (vars ? ";" + JSON.stringify(vars) : "");
+  },
+
+  /* For timestamp formatting reasons. */
+  language: {
+    code: "en-US"
   }
 };
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -82,17 +82,17 @@
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
   var mockSDK = _.extend({
     sendTextChatMessage: function(message) {
       dispatcher.dispatch(new loop.shared.actions.ReceivedTextChatMessage({
-        message: message
+        message: message.message
       }));
     }
   }, Backbone.Events);
 
   /**
    * Every view that uses an activeRoomStore needs its own; if they shared
    * an active store, they'd interfere with each other.
    *
@@ -304,40 +304,56 @@
       // use the fallback thumbnail
     }]
   }));
 
   textChatStore.setStoreState({textChatEnabled: true});
 
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
     contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
-    message: "Rheet!"
+    message: "Rheet!",
+    sentTimestamp: "2015-06-23T22:21:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
     contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
-    message: "Hi there"
+    message: "Hi there",
+    receivedTimestamp: "2015-06-23T22:21:45.590Z"
+  }));
+  dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
+    contentType: loop.store.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,
     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%"
+    "%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.SendTextChatMessage({
     contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
     message: "Nowforareallylongwordwithoutspacesorpunctuationwhichshouldcause" +
-    "linewrappingissuesifthecssiswrong"
+    "linewrappingissuesifthecssiswrong",
+    sentTimestamp: "2015-06-23T22:23:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
     contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
-    message: "That avocado monkey-brains pie sounds tasty!"
+    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,
-    message: "What time should we meet?"
+    message: "What time should we meet?",
+    sentTimestamp: "2015-06-23T22:27:45.590Z"
+  }));
+  dispatcher.dispatch(new sharedActions.SendTextChatMessage({
+    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    message: "Cool",
+    sentTimestamp: "2015-06-23T22:27:45.590Z"
   }));
 
   loop.store.StoreMixin.register({
     activeRoomStore: activeRoomStore,
     conversationStore: conversationStore,
     feedbackStore: feedbackStore,
     textChatStore: textChatStore
   });
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -82,17 +82,17 @@
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
   var mockSDK = _.extend({
     sendTextChatMessage: function(message) {
       dispatcher.dispatch(new loop.shared.actions.ReceivedTextChatMessage({
-        message: message
+        message: message.message
       }));
     }
   }, Backbone.Events);
 
   /**
    * Every view that uses an activeRoomStore needs its own; if they shared
    * an active store, they'd interfere with each other.
    *
@@ -304,40 +304,56 @@
       // use the fallback thumbnail
     }]
   }));
 
   textChatStore.setStoreState({textChatEnabled: true});
 
   dispatcher.dispatch(new sharedActions.SendTextChatMessage({
     contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
-    message: "Rheet!"
+    message: "Rheet!",
+    sentTimestamp: "2015-06-23T22:21:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
     contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
-    message: "Hi there"
+    message: "Hi there",
+    receivedTimestamp: "2015-06-23T22:21:45.590Z"
+  }));
+  dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
+    contentType: loop.store.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,
     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%"
+    "%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.SendTextChatMessage({
     contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
     message: "Nowforareallylongwordwithoutspacesorpunctuationwhichshouldcause" +
-    "linewrappingissuesifthecssiswrong"
+    "linewrappingissuesifthecssiswrong",
+    sentTimestamp: "2015-06-23T22:23:45.590Z"
   }));
   dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
     contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
-    message: "That avocado monkey-brains pie sounds tasty!"
+    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,
-    message: "What time should we meet?"
+    message: "What time should we meet?",
+    sentTimestamp: "2015-06-23T22:27:45.590Z"
+  }));
+  dispatcher.dispatch(new sharedActions.SendTextChatMessage({
+    contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
+    message: "Cool",
+    sentTimestamp: "2015-06-23T22:27:45.590Z"
   }));
 
   loop.store.StoreMixin.register({
     activeRoomStore: activeRoomStore,
     conversationStore: conversationStore,
     feedbackStore: feedbackStore,
     textChatStore: textChatStore
   });