Bug 1168833: introduce different sizing modes to docked social chat windows and re-style text chat UI indide the Hello conversation window. r=Standard8
authorMike de Boer <mdeboer@mozilla.com>
Fri, 12 Jun 2015 11:21:35 +0200
changeset 279290 56378c101d300fbdaa8ca570d89394b82e675e26
parent 279289 d02a12bf32f9706ed92356a5a682b014c0d48d67
child 279291 7be52f95b14ee4992af916df422abeda745d8063
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
bugs1168833
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 1168833: introduce different sizing modes to docked social chat windows and re-style text chat UI indide the Hello conversation window. r=Standard8
browser/base/content/browser.css
browser/base/content/socialchat.xml
browser/components/loop/content/js/roomViews.js
browser/components/loop/content/js/roomViews.jsx
browser/components/loop/content/shared/css/conversation.css
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/modules/MozLoopService.jsm
browser/components/loop/standalone/content/l10n/en-US/loop.properties
browser/components/loop/test/shared/textChatStore_test.js
browser/components/loop/test/shared/textChatView_test.js
browser/locales/en-US/chrome/browser/loop/loop.properties
browser/themes/shared/social/chat.inc.css
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -29,18 +29,18 @@
  */
 
 #chat-window {
  /*
   * In some ideal world, we'd have a simple way to express "block resizing
   * along any dimension beyond the point at which an overflow event would
   * occur".  But none of -moz-{fit,max,min}-content do what we want here. So..
   */
-  min-width: 320px;
-  min-height: 280px;
+  min-width: 260px;
+  min-height: 315px;
 }
 
 #main-window[customize-entered] {
   min-width: -moz-fit-content;
 }
 
 searchbar {
   -moz-binding: url("chrome://browser/content/search/search.xml#searchbar");
@@ -902,31 +902,64 @@ notification[value="loop-sharing-notific
 /* Note the chatbox 'width' values are duplicated in socialchat.xml */
 chatbox {
   -moz-binding: url("chrome://browser/content/socialchat.xml#chatbox");
   transition: height 150ms ease-out, width 150ms ease-out;
   height: 285px;
   width: 260px; /* CHAT_WIDTH_OPEN in socialchat.xml */
 }
 
-chatbox[large="true"] {
-  width: 300px;
+chatbox[customSize] {
+  width: 300px; /* CHAT_WIDTH_OPEN_ALT in socialchat.xml */
+}
+
+#chat-window[customSize] {
+  min-width: 300px;
+}
+
+chatbox[customSize="loopChatEnabled"] {
+  /* 325px as defined per UX */
+  height: 325px;
+}
+
+#chat-window[customSize="loopChatEnabled"] {
+  /* 325px + 30px top bar height. */
+  min-height: calc(325px + 30px);
+}
+
+chatbox[customSize="loopChatMessageAppended"] {
+  /* 445px as defined per UX */
+  height: 445px;
+}
+
+#chat-window[customSize="loopChatMessageAppended"] {
+  /* 445px + 30px top bar height. */
+  min-height: calc(445px + 30px);
 }
 
 chatbox[minimized="true"] {
   width: 160px;
   height: 20px; /* CHAT_WIDTH_MINIMIZED in socialchat.xml */
 }
 
 chatbar {
   -moz-binding: url("chrome://browser/content/socialchat.xml#chatbar");
   height: 0;
   max-height: 0;
 }
 
+.chatbar-innerbox {
+  margin: -285px 0 0;
+}
+
+chatbar[customSize] > .chatbar-innerbox {
+  /* 425px to make room for the maximum custom-size chatbox; currently 'loopChatMessageAppended'. */
+  margin-top: -425px;
+}
+
 /* Apply crisp rendering for favicons at exactly 2dppx resolution */
 @media (resolution: 2dppx) {
   #social-sidebar-favico,
   .social-status-button,
   .chat-status-icon {
     image-rendering: -moz-crisp-edges;
   }
 }
--- a/browser/base/content/socialchat.xml
+++ b/browser/base/content/socialchat.xml
@@ -155,25 +155,34 @@
                                                 this.content);
         ]]></body>
       </method>
 
       <method name="swapDocShells">
         <parameter name="aTarget"/>
         <body><![CDATA[
           aTarget.setAttribute("label", this.contentDocument.title);
-          if (this.getAttribute("dark") == "true")
-            aTarget.setAttribute("dark", "true");
+
           aTarget.src = this.src;
           aTarget.content.setAttribute("origin", this.content.getAttribute("origin"));
           aTarget.content.popupnotificationanchor.className = this.content.popupnotificationanchor.className;
           aTarget.content.swapDocShells(this.content);
         ]]></body>
       </method>
 
+      <method name="setDecorationAttributes">
+        <parameter name="aTarget"/>
+        <body><![CDATA[
+          for (let attr of ["dark", "customSize"]) {
+            if (this.hasAttribute(attr))
+              aTarget.setAttribute(attr, this.getAttribute(attr));
+          }
+        ]]></body>
+      </method>
+
       <method name="onTitlebarClick">
         <parameter name="aEvent"/>
         <body><![CDATA[
           if (!this.chatbar)
             return;
           if (aEvent.button == 0) { // left-click: toggle minimized.
             this.toggle();
             // if we restored it, we want to focus it.
@@ -206,30 +215,38 @@
           );
         } else {
           // attach this chatbox to the topmost browser window
           let Chat = Cu.import("resource:///modules/Chat.jsm").Chat;
           let win = Chat.findChromeWindowForChats();
           let chatbar = win.document.getElementById("pinnedchats");
           let origin = this.content.getAttribute("origin");
           let cb = chatbar.openChat(origin, title, "about:blank");
+          this.setDecorationAttributes(cb);
+
           cb.promiseChatLoaded.then(
             () => {
               this.swapDocShells(cb);
 
               chatbar.focus();
               this.close();
 
               // chatboxForURL is a map of URL -> chatbox used to avoid opening
               // duplicate chat windows. Ensure reattached chat windows aren't
               // registered with about:blank as their URL, otherwise reattaching
               // more than one chat window isn't possible.
               chatbar.chatboxForURL.delete("about:blank");
               chatbar.chatboxForURL.set(this.src, Cu.getWeakReference(cb));
 
+              let attachEvent = new cb.contentWindow.CustomEvent("socialFrameAttached", {
+                bubbles: true,
+                cancelable: true,
+              });
+              cb.contentDocument.dispatchEvent(attachEvent);
+
               deferred.resolve(cb);
             }
           );
         }
         return deferred.promise;
         ]]></body>
       </method>
 
@@ -452,18 +469,20 @@
       </method>
 
       <method name="getTotalChildWidth">
         <parameter name="aChatbox"/>
         <body><![CDATA[
           // These are from the CSS for the chatbox and must be kept in sync.
           // We can't use calcTotalWidthOf due to the transitions...
           const CHAT_WIDTH_OPEN = 260;
+          const CHAT_WIDTH_OPEN_ALT = 300;
           const CHAT_WIDTH_MINIMIZED = 160;
-          let openWidth = aChatbox.hasAttribute("large") ? 300 : CHAT_WIDTH_OPEN;
+          let openWidth = aChatbox.hasAttribute("customSize") ?
+            CHAT_WIDTH_OPEN_ALT : CHAT_WIDTH_OPEN;
 
           return aChatbox.minimized ? CHAT_WIDTH_MINIMIZED : openWidth;
         ]]></body>
       </method>
 
       <method name="collapseChat">
         <parameter name="aChatbox"/>
         <body><![CDATA[
@@ -679,28 +698,37 @@
 
           let otherWin = window.openDialog("chrome://browser/content/chatWindow.xul",
                                            "_blank", "chrome,all,dialog=no" + options);
 
           otherWin.addEventListener("load", function _chatLoad(event) {
             if (event.target != otherWin.document)
               return;
 
+            if (aChatbox.hasAttribute("customSize")) {
+              otherWin.document.getElementById("chat-window").
+                setAttribute("customSize", aChatbox.getAttribute("customSize"));
+            }
+
+            let document = aChatbox.contentDocument;
             let detachEvent = new aChatbox.contentWindow.CustomEvent("socialFrameDetached", {
               bubbles: true,
               cancelable: true,
             });
-            aChatbox.contentDocument.dispatchEvent(detachEvent);
 
             otherWin.removeEventListener("load", _chatLoad, true);
             let otherChatbox = otherWin.document.getElementById("chatter");
+            aChatbox.setDecorationAttributes(otherChatbox);
             aChatbox.swapDocShells(otherChatbox);
             aChatbox.close();
             chatbar.chatboxForURL.set(aChatbox.src, Cu.getWeakReference(otherChatbox));
 
+            // All processing is done, now we can fire the event.
+            document.dispatchEvent(detachEvent);
+
             deferred.resolve(otherChatbox);
           }, true);
           return deferred.promise;
         ]]></body>
       </method>
 
     </implementation>
 
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -713,17 +713,16 @@ loop.roomViews = (function(mozL10n) {
             React.createElement(sharedViews.FeedbackView, {
               onAfterFeedbackReceived: this.closeWindow})
           );
         }
         default: {
 
           return (
             React.createElement("div", {className: "room-conversation-wrapper"}, 
-              React.createElement(sharedViews.TextChatView, {dispatcher: this.props.dispatcher}), 
               React.createElement(DesktopRoomInvitationView, {
                 dispatcher: this.props.dispatcher, 
                 error: this.state.error, 
                 mozLoop: this.props.mozLoop, 
                 roomData: roomData, 
                 savingContext: this.state.savingContext, 
                 show: shouldRenderInvitationOverlay, 
                 showContext: shouldRenderContextView, 
@@ -756,17 +755,18 @@ loop.roomViews = (function(mozL10n) {
                 )
               ), 
               React.createElement(DesktopRoomContextView, {
                 dispatcher: this.props.dispatcher, 
                 error: this.state.error, 
                 savingContext: this.state.savingContext, 
                 mozLoop: this.props.mozLoop, 
                 roomData: roomData, 
-                show: !shouldRenderInvitationOverlay && shouldRenderContextView})
+                show: !shouldRenderInvitationOverlay && shouldRenderContextView}), 
+              React.createElement(sharedViews.TextChatView, {dispatcher: this.props.dispatcher})
             )
           );
         }
       }
     }
   });
 
   return {
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -713,17 +713,16 @@ loop.roomViews = (function(mozL10n) {
             <sharedViews.FeedbackView
               onAfterFeedbackReceived={this.closeWindow} />
           );
         }
         default: {
 
           return (
             <div className="room-conversation-wrapper">
-              <sharedViews.TextChatView dispatcher={this.props.dispatcher} />
               <DesktopRoomInvitationView
                 dispatcher={this.props.dispatcher}
                 error={this.state.error}
                 mozLoop={this.props.mozLoop}
                 roomData={roomData}
                 savingContext={this.state.savingContext}
                 show={shouldRenderInvitationOverlay}
                 showContext={shouldRenderContextView}
@@ -757,16 +756,17 @@ loop.roomViews = (function(mozL10n) {
               </div>
               <DesktopRoomContextView
                 dispatcher={this.props.dispatcher}
                 error={this.state.error}
                 savingContext={this.state.savingContext}
                 mozLoop={this.props.mozLoop}
                 roomData={roomData}
                 show={!shouldRenderInvitationOverlay && shouldRenderContextView} />
+              <sharedViews.TextChatView dispatcher={this.props.dispatcher} />
             </div>
           );
         }
       }
     }
   });
 
   return {
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -646,63 +646,38 @@
  * on the video element
  * */
 html, .fx-embedded, #main,
 .video-layout-wrapper,
 .conversation {
   height: 100%;
 }
 
-/**
- * The .fx-embbeded .text-chat-* styles are very temporarily whilst we work on
- * text chat (bug 1108892 and dependencies).
- */
-.fx-embedded .text-chat-view {
-  height: 60px;
-  color: white;
-  background-color: black;
-}
-
-.fx-embedded .text-chat-entries {
-  /* XXX Should use flex, this is just for the initial implementation. */
-  height: calc(100% - 2em);
-  width: 100%;
-}
-
-.fx-embedded .text-chat-box {
-  width: 100%;
-  margin: auto;
-}
-
 /* We use 641px rather than 640, as min-width and max-width are inclusive */
 @media screen and (min-width:641px) {
   .standalone .conversation-toolbar {
     position: absolute;
     bottom: 0;
     left: 0;
     right: 0;
   }
 
-  .fx-embedded .local-stream {
-    position: fixed;
-  }
-
   .standalone .local-stream,
   .standalone .remote-inset-stream {
     position: absolute;
     right: 15px;
     bottom: 15px;
     width: 20%;
     height: 20%;
     max-width: 400px;
     max-height: 300px;
   }
 
   /* Nested video elements */
-  .conversation .media.nested {
+  .standalone .conversation .media.nested {
     position: relative;
     height: 100%;
   }
 
   .standalone .media.nested {
     margin-left: 10px;
   }
 
@@ -739,17 +714,17 @@ html, .fx-embedded, #main,
     flex: 1;
     min-width: 120px;
     min-height: 150px;
     width: 100%;
     box-shadow: none;
   }
 
   /* Nested video elements */
-  .conversation .media.nested {
+  .standalone .conversation .media.nested {
     display: flex;
     flex-direction: column;
     align-items: center;
     justify-content: center;
     flex: 1 1 0%;
   }
 
   .standalone .video_wrapper.remote_wrapper {
@@ -1234,36 +1209,90 @@ html[dir="rtl"] .room-context-btn-edit {
   display: block;
 }
 
 .standalone .room-conversation-wrapper .ended-conversation {
   position: relative;
   height: auto;
 }
 
+/* Text chat in rooms styles */
+
+.fx-embedded .room-conversation-wrapper {
+  display: flex;
+  flex-flow: column nowrap;
+}
+
+.fx-embedded .video-layout-wrapper {
+  flex: 1 1 auto;
+}
+
+.text-chat-view {
+  background: #fff;
+}
+
+.fx-embedded .text-chat-view {
+  flex: 1 0 auto;
+  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;
+}
+
+.fx-embedded .text-chat-box {
+  flex: 0 0 auto;
+  max-height: 40px;
+  min-height: 40px;
+  width: 100%;
+}
+
 .text-chat-entries {
-  margin: auto;
   overflow: scroll;
-  border: 1px solid red;
 }
 
 .text-chat-entry {
-  text-align: left;
+  text-align: end;
+  margin-bottom: 1.5em;
+}
+
+.text-chat-entry > span {
+  border-width: 1px;
+  border-style: solid;
+  border-color: #0095dd;
+  border-radius: 10000px;
+  padding: .5em 1em;
 }
 
 .text-chat-entry.received {
-  text-align: right;
+  text-align: start;
+}
+
+.text-chat-entry.received > span {
+  border-color: #d8d8d8;
 }
 
 .text-chat-box {
   margin: auto;
 }
 
 .text-chat-box > form > input {
   width: 100%;
+  height: 40px;
+  padding: 0 .5em .5em;
+  font-size: 1.1em;
+}
+
+.fx-embedded .text-chat-box > form > input {
+  border: 0;
+  border-top: 1px solid #999;
 }
 
 @media screen and (max-width:640px) {
   .standalone-room-info {
     /* This isn't perfect, we just center the heading for now. Bug 1141493
        should fix this. */
     position: absolute;
     width: 100%;
--- a/browser/components/loop/content/shared/js/textChatStore.js
+++ b/browser/components/loop/content/shared/js/textChatStore.js
@@ -63,51 +63,59 @@ loop.store.TextChatStore = (function() {
     },
 
     /**
      * Handles information for when data channels are available - enables
      * text chat.
      */
     dataChannelsAvailable: function() {
       this.setStoreState({ textChatEnabled: true });
+      window.dispatchEvent(new CustomEvent("LoopChatEnabled"));
+    },
+
+    /**
+     * Appends a message to the store, which may be of type 'sent' or 'received'.
+     *
+     * @param {String} type
+     * @param {sharedActions.ReceivedTextChatMessage|sharedActions.SendTextChatMessage} actionData
+     */
+    _appendTextChatMessage: function(type, actionData) {
+      // We create a new list to avoid updating the store's state directly,
+      // which confuses the views.
+      var message = {
+        type: type,
+        contentType: actionData.contentType,
+        message: actionData.message
+      };
+      var newList = this._storeState.messageList.concat(message);
+      this.setStoreState({ messageList: newList });
+
+      window.dispatchEvent(new CustomEvent("LoopChatMessageAppended"));
     },
 
     /**
      * Handles received text chat messages.
      *
      * @param {sharedActions.ReceivedTextChatMessage} actionData
      */
     receivedTextChatMessage: function(actionData) {
       // If we don't know how to deal with this content, then skip it
       // as this version doesn't support it.
       if (actionData.contentType != CHAT_CONTENT_TYPES.TEXT) {
         return;
       }
-      // We create a new list to avoid updating the store's state directly,
-      // which confuses the views.
-      var newList = this._storeState.messageList.concat({
-        type: CHAT_MESSAGE_TYPES.RECEIVED,
-        contentType: actionData.contentType,
-        message: actionData.message
-      });
-      this.setStoreState({ messageList: newList });
+
+      this._appendTextChatMessage(CHAT_MESSAGE_TYPES.RECEIVED, actionData);
     },
 
     /**
      * Handles sending of a chat message.
      *
      * @param {sharedActions.SendTextChatMessage} actionData
      */
     sendTextChatMessage: function(actionData) {
-      // We create a new list to avoid updating the store's state directly,
-      // which confuses the views.
-      var newList = this._storeState.messageList.concat({
-        type: CHAT_MESSAGE_TYPES.SENT,
-        contentType: actionData.contentType,
-        message: actionData.message
-      });
+      this._appendTextChatMessage(CHAT_MESSAGE_TYPES.SENT, actionData);
       this._sdkDriver.sendTextChatMessage(actionData);
-      this.setStoreState({ messageList: newList });
     }
   });
 
   return TextChatStore;
 })();
--- a/browser/components/loop/content/shared/js/textChatView.js
+++ b/browser/components/loop/content/shared/js/textChatView.js
@@ -24,17 +24,17 @@ loop.shared.views.TextChatView = (functi
     render: function() {
       var classes = React.addons.classSet({
         "text-chat-entry": true,
         "received": this.props.type === CHAT_MESSAGE_TYPES.RECEIVED
       });
 
       return (
         React.createElement("div", {className: classes}, 
-          this.props.message
+          React.createElement("span", null, this.props.message)
         )
       );
     }
   });
 
   /**
    * Manages the text entries in the chat entries view. This is split out from
    * TextChatView so that scrolling can be managed more efficiently - this
@@ -44,42 +44,51 @@ loop.shared.views.TextChatView = (functi
     mixins: [React.addons.PureRenderMixin],
 
     propTypes: {
       messageList: React.PropTypes.array.isRequired
     },
 
     componentWillUpdate: function() {
       var node = this.getDOMNode();
+      if (!node) {
+        return;
+      }
       // Scroll only if we're right at the bottom of the display.
       this.shouldScroll = node.scrollHeight === node.scrollTop + node.clientHeight;
     },
 
     componentDidUpdate: function() {
       if (this.shouldScroll) {
         // This ensures the paint is complete.
         window.requestAnimationFrame(function() {
           var node = this.getDOMNode();
           node.scrollTop = node.scrollHeight - node.clientHeight;
         }.bind(this));
       }
     },
 
     render: function() {
+      if (!this.props.messageList.length) {
+        return null;
+      }
+
       return (
         React.createElement("div", {className: "text-chat-entries"}, 
-          
-            this.props.messageList.map(function(entry, i) {
-              return (
-                React.createElement(TextChatEntry, {key: i, 
-                               message: entry.message, 
-                               type: entry.type})
-              );
-            }, this)
-          
+          React.createElement("div", {className: "text-chat-scroller"}, 
+            
+              this.props.messageList.map(function(entry, i) {
+                return (
+                  React.createElement(TextChatEntry, {key: i, 
+                                 message: entry.message, 
+                                 type: entry.type})
+                );
+              }, this)
+            
+          )
         )
       );
     }
   });
 
   /**
    * Displays the text chat view. This includes the text chat messages as well
    * as a field for entering new messages.
@@ -129,22 +138,25 @@ loop.shared.views.TextChatView = (functi
       this.setState({ messageDetail: "" });
     },
 
     render: function() {
       if (!this.state.textChatEnabled) {
         return null;
       }
 
+      var messageList = this.state.messageList;
+
       return (
         React.createElement("div", {className: "text-chat-view"}, 
-          React.createElement(TextChatEntriesView, {messageList: this.state.messageList}), 
+          React.createElement(TextChatEntriesView, {messageList: messageList}), 
           React.createElement("div", {className: "text-chat-box"}, 
             React.createElement("form", {onSubmit: this.handleFormSubmit}, 
               React.createElement("input", {type: "text", 
+                     placeholder: messageList.length ? "" : mozl10n.get("chat_textbox_placeholder"), 
                      onKeyDown: this.handleKeyDown, 
                      valueLink: this.linkState("messageDetail")})
             )
           )
         )
       );
     }
   });
--- a/browser/components/loop/content/shared/js/textChatView.jsx
+++ b/browser/components/loop/content/shared/js/textChatView.jsx
@@ -24,17 +24,17 @@ loop.shared.views.TextChatView = (functi
     render: function() {
       var classes = React.addons.classSet({
         "text-chat-entry": true,
         "received": this.props.type === CHAT_MESSAGE_TYPES.RECEIVED
       });
 
       return (
         <div className={classes}>
-          {this.props.message}
+          <span>{this.props.message}</span>
         </div>
       );
     }
   });
 
   /**
    * Manages the text entries in the chat entries view. This is split out from
    * TextChatView so that scrolling can be managed more efficiently - this
@@ -44,42 +44,51 @@ loop.shared.views.TextChatView = (functi
     mixins: [React.addons.PureRenderMixin],
 
     propTypes: {
       messageList: React.PropTypes.array.isRequired
     },
 
     componentWillUpdate: function() {
       var node = this.getDOMNode();
+      if (!node) {
+        return;
+      }
       // Scroll only if we're right at the bottom of the display.
       this.shouldScroll = node.scrollHeight === node.scrollTop + node.clientHeight;
     },
 
     componentDidUpdate: function() {
       if (this.shouldScroll) {
         // This ensures the paint is complete.
         window.requestAnimationFrame(function() {
           var node = this.getDOMNode();
           node.scrollTop = node.scrollHeight - node.clientHeight;
         }.bind(this));
       }
     },
 
     render: function() {
+      if (!this.props.messageList.length) {
+        return null;
+      }
+
       return (
         <div className="text-chat-entries">
-          {
-            this.props.messageList.map(function(entry, i) {
-              return (
-                <TextChatEntry key={i}
-                               message={entry.message}
-                               type={entry.type} />
-              );
-            }, this)
-          }
+          <div className="text-chat-scroller">
+            {
+              this.props.messageList.map(function(entry, i) {
+                return (
+                  <TextChatEntry key={i}
+                                 message={entry.message}
+                                 type={entry.type} />
+                );
+              }, this)
+            }
+          </div>
         </div>
       );
     }
   });
 
   /**
    * Displays the text chat view. This includes the text chat messages as well
    * as a field for entering new messages.
@@ -129,22 +138,25 @@ loop.shared.views.TextChatView = (functi
       this.setState({ messageDetail: "" });
     },
 
     render: function() {
       if (!this.state.textChatEnabled) {
         return null;
       }
 
+      var messageList = this.state.messageList;
+
       return (
         <div className="text-chat-view">
-          <TextChatEntriesView messageList={this.state.messageList} />
+          <TextChatEntriesView messageList={messageList} />
           <div className="text-chat-box">
             <form onSubmit={this.handleFormSubmit}>
               <input type="text"
+                     placeholder={messageList.length ? "" : mozl10n.get("chat_textbox_placeholder")}
                      onKeyDown={this.handleKeyDown}
                      valueLink={this.linkState("messageDetail")} />
             </form>
           </div>
         </div>
       );
     }
   });
--- a/browser/components/loop/modules/MozLoopService.jsm
+++ b/browser/components/loop/modules/MozLoopService.jsm
@@ -848,37 +848,61 @@ let MozLoopServiceInternal = {
       // in about:blank and then get lost.
       // Sadly we can't use chatbox.promiseChatLoaded() as promise chaining
       // involves event loop spins, which means it might be too late.
       // Have we already done it?
       if (chatbox.contentWindow.navigator.mozLoop) {
         return;
       }
 
-      chatbox.setAttribute("dark", true);
-      chatbox.setAttribute("large", true);
-
       chatbox.addEventListener("DOMContentLoaded", function loaded(event) {
         if (event.target != chatbox.contentDocument) {
           return;
         }
         chatbox.removeEventListener("DOMContentLoaded", loaded, true);
 
+        let chatbar = chatbox.parentNode;
         let window = chatbox.contentWindow;
 
         function socialFrameChanged(eventName) {
           UITour.availableTargetsCache.clear();
           UITour.notify(eventName);
+
+          if (eventName == "Loop:ChatWindowDetached" || eventName == "Loop:ChatWindowAttached") {
+            // After detach, re-attach of the chatbox, refresh its reference so
+            // we can keep using it here.
+            let ref = chatbar.chatboxForURL.get(chatbox.src);
+            chatbox = ref && ref.get() || chatbox;
+          }
         }
 
         window.addEventListener("socialFrameHide", socialFrameChanged.bind(null, "Loop:ChatWindowHidden"));
         window.addEventListener("socialFrameShow", socialFrameChanged.bind(null, "Loop:ChatWindowShown"));
         window.addEventListener("socialFrameDetached", socialFrameChanged.bind(null, "Loop:ChatWindowDetached"));
+        window.addEventListener("socialFrameAttached", socialFrameChanged.bind(null, "Loop:ChatWindowAttached"));
         window.addEventListener("unload", socialFrameChanged.bind(null, "Loop:ChatWindowClosed"));
 
+        const kSizeMap = {
+          LoopChatEnabled: "loopChatEnabled",
+          LoopChatMessageAppended: "loopChatMessageAppended"
+        };
+
+        function onChatEvent(event) {
+          // When the chat box or messages are shown, resize the panel or window
+          // to be slightly higher to accomodate them.
+          let customSize = kSizeMap[event.type];
+          if (customSize) {
+            chatbox.setAttribute("customSize", customSize);
+            chatbox.parentNode.setAttribute("customSize", customSize);
+          }
+        }
+
+        window.addEventListener("LoopChatEnabled", onChatEvent);
+        window.addEventListener("LoopChatMessageAppended", onChatEvent);
+
         injectLoopAPI(window);
 
         let ourID = window.QueryInterface(Ci.nsIInterfaceRequestor)
             .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID;
 
         let onPCLifecycleChange = (pc, winID, type) => {
           if (winID != ourID) {
             return;
@@ -913,18 +937,26 @@ let MozLoopServiceInternal = {
 
         let pc_static = new window.mozRTCPeerConnectionStatic();
         pc_static.registerPeerConnectionLifecycleCallback(onPCLifecycleChange);
 
         UITour.notify("Loop:ChatWindowOpened");
       }.bind(this), true);
     };
 
-    if (!Chat.open(null, origin, "", url, undefined, undefined, callback)) {
+    let chatbox = Chat.open(null, origin, "", url, undefined, undefined, callback);
+    if (!chatbox) {
       return null;
+    // It's common for unit tests to overload Chat.open.
+    } else if (chatbox.setAttribute) {
+      // Set properties that influence visual appeara nce of the chatbox right
+      // away to circumvent glitches.
+      chatbox.setAttribute("dark", true);
+      chatbox.setAttribute("customSize", "loopDefault");
+      chatbox.parentNode.setAttribute("customSize", "loopDefault");
     }
     return windowId;
   },
 
   /**
    * Fetch Firefox Accounts (FxA) OAuth parameters from the Loop Server.
    *
    * @return {Promise} resolved with the body of the hawk request for OAuth parameters.
--- a/browser/components/loop/standalone/content/l10n/en-US/loop.properties
+++ b/browser/components/loop/standalone/content/l10n/en-US/loop.properties
@@ -132,8 +132,12 @@ room_information_failure_unsupported_bro
 ## 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
 support_link=Get Help
+
+# Text chat strings
+
+chat_textbox_placeholder=Type here…
--- a/browser/components/loop/test/shared/textChatStore_test.js
+++ b/browser/components/loop/test/shared/textChatStore_test.js
@@ -20,28 +20,41 @@ describe("loop.store.TextChatStore", fun
 
     fakeSdkDriver = {
       sendTextChatMessage: sinon.stub()
     };
 
     store = new loop.store.TextChatStore(dispatcher, {
       sdkDriver: fakeSdkDriver
     });
+
+    sandbox.stub(window, "dispatchEvent");
+    sandbox.stub(window, "CustomEvent", function(name) {
+      this.name = name;
+    });
   });
 
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("#dataChannelsAvailable", function() {
     it("should set textChatEnabled to true", function() {
       store.dataChannelsAvailable();
 
       expect(store.getStoreState("textChatEnabled")).eql(true);
     });
+
+    it("should dispatch a LoopChatEnabled event", function() {
+      store.dataChannelsAvailable();
+
+      sinon.assert.calledOnce(window.dispatchEvent);
+      sinon.assert.calledWithExactly(window.dispatchEvent,
+        new CustomEvent("LoopChatEnabled"));
+    });
   });
 
   describe("#receivedTextChatMessage", function() {
     it("should add the message to the list", function() {
       var message = "Hello!";
 
       store.receivedTextChatMessage({
         contentType: CHAT_CONTENT_TYPES.TEXT,
@@ -58,16 +71,27 @@ describe("loop.store.TextChatStore", fun
     it("should not add messages for unknown content types", function() {
       store.receivedTextChatMessage({
         contentType: "invalid type",
         message: "Hi"
       });
 
       expect(store.getStoreState("messageList").length).eql(0);
     });
+
+    it("should dispatch a LoopChatMessageAppended event", function() {
+      store.receivedTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!"
+      });
+
+      sinon.assert.calledOnce(window.dispatchEvent);
+      sinon.assert.calledWithExactly(window.dispatchEvent,
+        new CustomEvent("LoopChatMessageAppended"));
+    });
   });
 
   describe("#sendTextChatMessage", function() {
     it("should send the message", function() {
       var messageData = {
         contentType: CHAT_CONTENT_TYPES.TEXT,
         message: "Yes, that's what this is called."
       };
@@ -87,10 +111,21 @@ describe("loop.store.TextChatStore", fun
       store.sendTextChatMessage(messageData);
 
       expect(store.getStoreState("messageList")).eql([{
         type: CHAT_MESSAGE_TYPES.SENT,
         contentType: messageData.contentType,
         message: messageData.message
       }]);
     });
+
+    it("should dipatch a LoopChatMessageAppended event", function() {
+      store.sendTextChatMessage({
+        contentType: CHAT_CONTENT_TYPES.TEXT,
+        message: "Hello!"
+      });
+
+      sinon.assert.calledOnce(window.dispatchEvent);
+      sinon.assert.calledWithExactly(window.dispatchEvent,
+        new CustomEvent("LoopChatMessageAppended"));
+    });
   });
 });
--- a/browser/components/loop/test/shared/textChatView_test.js
+++ b/browser/components/loop/test/shared/textChatView_test.js
@@ -75,10 +75,37 @@ describe("loop.shared.views.TextChatView
 
       sinon.assert.calledOnce(dispatcher.dispatch);
       sinon.assert.calledWithExactly(dispatcher.dispatch,
         new sharedActions.SendTextChatMessage({
           contentType: CHAT_CONTENT_TYPES.TEXT,
           message: "Hello!"
         }));
     });
+
+    it("should not render message entries when none are sent/ received yet", function() {
+      view = mountTestComponent();
+
+      expect(view.getDOMNode().querySelector(".text-chat-entries")).to.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);
+    });
   });
 });
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -349,8 +349,12 @@ context_edit_name_placeholder=Conversati
 context_edit_comments_placeholder=Comments
 context_add_some_label=Add some context
 context_edit_tooltip=Edit Context
 context_hide_tooltip=Hide Context
 context_show_tooltip=Show Context
 context_save_label2=Save
 context_link_modified=This link was modified.
 context_learn_more_link_label=Learn more.
+
+# Text chat strings
+
+chat_textbox_placeholder=Type here…
--- a/browser/themes/shared/social/chat.inc.css
+++ b/browser/themes/shared/social/chat.inc.css
@@ -190,17 +190,16 @@ chatbox[dark=true] > .chat-titlebar > hb
 }
 
 .chatbar-button > menupopup > menuitem[activity] {
   font-weight: bold;
 }
 
 .chatbar-innerbox {
   background: transparent;
-  margin: -285px 0 0;
   overflow: hidden;
 }
 
 chatbar {
   -moz-margin-end: 20px;
 }
 
 chatbox {