Bug 1020449 Loop should show caller information on incoming calls. Patch originally by Andrei, updated and polished by Standard8. r=nperriault a=lmandel
authorAndrei Oprea <andrei.br92@gmail.com>
Fri, 10 Oct 2014 10:19:45 +0100
changeset 225719 742beda04394
parent 225718 9be2b1620955
child 225720 0033bca3ce22
push id3990
push userrjesup@wgate.com
push date2014-10-17 02:29 +0000
treeherdermozilla-beta@530ec559a14c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnperriault, lmandel
bugs1020449
milestone34.0
Bug 1020449 Loop should show caller information on incoming calls. Patch originally by Andrei, updated and polished by Standard8. r=nperriault a=lmandel
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/conversationViews.js
browser/components/loop/content/js/conversationViews.jsx
browser/components/loop/content/js/panel.js
browser/components/loop/content/js/panel.jsx
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/content/shared/js/models.js
browser/components/loop/content/shared/js/utils.js
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/shared/models_test.js
browser/components/loop/test/shared/utils_test.js
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -10,16 +10,17 @@
 var loop = loop || {};
 loop.conversation = (function(mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedMixins = loop.shared.mixins;
   var sharedModels = loop.shared.models;
   var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
+  var CallIdentifierView = loop.conversationViews.CallIdentifierView;
 
   var IncomingCallView = React.createClass({displayName: 'IncomingCallView',
     mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
@@ -88,19 +89,24 @@ loop.conversation = (function(mozL10n) {
 
     render: function() {
       /* jshint ignore:start */
       var dropdownMenuClassesDecline = React.addons.classSet({
         "native-dropdown-menu": true,
         "conversation-window-dropdown": true,
         "visually-hidden": !this.state.showMenu
       });
+
       return (
         React.DOM.div({className: "call-window"}, 
-          React.DOM.h2(null, mozL10n.get("incoming_call_title2")), 
+          CallIdentifierView({video: this.props.video, 
+            peerIdentifier: this.props.model.getCallIdentifier(), 
+            urlCreationDate: this.props.model.get("urlCreationDate"), 
+            showIcons: true}), 
+
           React.DOM.div({className: "btn-group call-action-group"}, 
 
             React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
 
             React.DOM.div({className: "btn-chevron-menu-group"}, 
               React.DOM.div({className: "btn-group-chevron"}, 
                 React.DOM.div({className: "btn-group"}, 
 
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -10,16 +10,17 @@
 var loop = loop || {};
 loop.conversation = (function(mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedMixins = loop.shared.mixins;
   var sharedModels = loop.shared.models;
   var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
+  var CallIdentifierView = loop.conversationViews.CallIdentifierView;
 
   var IncomingCallView = React.createClass({
     mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
@@ -88,19 +89,24 @@ loop.conversation = (function(mozL10n) {
 
     render: function() {
       /* jshint ignore:start */
       var dropdownMenuClassesDecline = React.addons.classSet({
         "native-dropdown-menu": true,
         "conversation-window-dropdown": true,
         "visually-hidden": !this.state.showMenu
       });
+
       return (
         <div className="call-window">
-          <h2>{mozL10n.get("incoming_call_title2")}</h2>
+          <CallIdentifierView video={this.props.video}
+            peerIdentifier={this.props.model.getCallIdentifier()}
+            urlCreationDate={this.props.model.get("urlCreationDate")}
+            showIcons={true} />
+
           <div className="btn-group call-action-group">
 
             <div className="fx-embedded-call-button-spacer"></div>
 
             <div className="btn-chevron-menu-group">
               <div className="btn-group-chevron">
                 <div className="btn-group">
 
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -10,16 +10,83 @@ var loop = loop || {};
 loop.conversationViews = (function(mozL10n) {
 
   var CALL_STATES = loop.store.CALL_STATES;
   var CALL_TYPES = loop.shared.utils.CALL_TYPES;
   var sharedActions = loop.shared.actions;
   var sharedViews = loop.shared.views;
 
   /**
+   * Displays information about the call
+   * Caller avatar, name & conversation creation date
+   */
+  var CallIdentifierView = React.createClass({displayName: 'CallIdentifierView',
+    propTypes: {
+      peerIdentifier: React.PropTypes.string,
+      showIcons: React.PropTypes.bool.isRequired,
+      urlCreationDate: React.PropTypes.string,
+      video: React.PropTypes.bool
+    },
+
+    getDefaultProps: function() {
+      return {
+        peerIdentifier: "",
+        showLinkDetail: true,
+        urlCreationDate: "",
+        video: true
+      };
+    },
+
+    getInitialState: function() {
+      return {timestamp: 0};
+    },
+
+    /**
+     * Gets and formats the incoming call creation date
+     */
+    formatCreationDate: function() {
+      if (!this.props.urlCreationDate) {
+        return "";
+      }
+
+      var timestamp = this.props.urlCreationDate;
+      return "(" + loop.shared.utils.formatDate(timestamp) + ")";
+    },
+
+    render: function() {
+      var iconVideoClasses = React.addons.classSet({
+        "fx-embedded-tiny-video-icon": true,
+        "muted": !this.props.video
+      });
+      var callDetailClasses = React.addons.classSet({
+        "fx-embedded-call-detail": true,
+        "hide": !this.props.showIcons
+      });
+
+      return (
+        React.DOM.div({className: "fx-embedded-call-identifier"}, 
+          React.DOM.div({className: "fx-embedded-call-identifier-avatar fx-embedded-call-identifier-item"}), 
+          React.DOM.div({className: "fx-embedded-call-identifier-info fx-embedded-call-identifier-item"}, 
+            React.DOM.div({className: "fx-embedded-call-identifier-text overflow-text-ellipsis font-bold"}, 
+              this.props.peerIdentifier
+            ), 
+            React.DOM.div({className: callDetailClasses}, 
+              React.DOM.span({className: "fx-embedded-tiny-audio-icon"}), 
+              React.DOM.span({className: iconVideoClasses}), 
+              React.DOM.span({className: "fx-embedded-conversation-timestamp"}, 
+                this.formatCreationDate()
+              )
+            )
+          )
+        )
+      );
+    }
+  });
+
+  /**
    * Displays details of the incoming/outgoing conversation
    * (name, link, audio/video type etc).
    *
    * Allows the view to be extended with different buttons and progress
    * via children properties.
    */
   var ConversationDetailView = React.createClass({displayName: 'ConversationDetailView',
     propTypes: {
@@ -46,17 +113,19 @@ loop.conversationViews = (function(mozL1
       } else {
         contactName = this._getPreferredEmail(this.props.contact).value;
       }
 
       document.title = contactName;
 
       return (
         React.DOM.div({className: "call-window"}, 
-          React.DOM.h2(null, contactName), 
+          CallIdentifierView({
+            peerIdentifier: contactName, 
+            showIcons: false}), 
           React.DOM.div(null, this.props.children)
         )
       );
     }
   });
 
   /**
    * View for pending conversations. Displays a cancel button and appropriate
@@ -377,15 +446,16 @@ loop.conversationViews = (function(mozL1
           ))
         }
       }
     },
   });
 
   return {
     PendingConversationView: PendingConversationView,
+    CallIdentifierView: CallIdentifierView,
     ConversationDetailView: ConversationDetailView,
     CallFailedView: CallFailedView,
     OngoingConversationView: OngoingConversationView,
     OutgoingConversationView: OutgoingConversationView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -10,16 +10,83 @@ var loop = loop || {};
 loop.conversationViews = (function(mozL10n) {
 
   var CALL_STATES = loop.store.CALL_STATES;
   var CALL_TYPES = loop.shared.utils.CALL_TYPES;
   var sharedActions = loop.shared.actions;
   var sharedViews = loop.shared.views;
 
   /**
+   * Displays information about the call
+   * Caller avatar, name & conversation creation date
+   */
+  var CallIdentifierView = React.createClass({
+    propTypes: {
+      peerIdentifier: React.PropTypes.string,
+      showIcons: React.PropTypes.bool.isRequired,
+      urlCreationDate: React.PropTypes.string,
+      video: React.PropTypes.bool
+    },
+
+    getDefaultProps: function() {
+      return {
+        peerIdentifier: "",
+        showLinkDetail: true,
+        urlCreationDate: "",
+        video: true
+      };
+    },
+
+    getInitialState: function() {
+      return {timestamp: 0};
+    },
+
+    /**
+     * Gets and formats the incoming call creation date
+     */
+    formatCreationDate: function() {
+      if (!this.props.urlCreationDate) {
+        return "";
+      }
+
+      var timestamp = this.props.urlCreationDate;
+      return "(" + loop.shared.utils.formatDate(timestamp) + ")";
+    },
+
+    render: function() {
+      var iconVideoClasses = React.addons.classSet({
+        "fx-embedded-tiny-video-icon": true,
+        "muted": !this.props.video
+      });
+      var callDetailClasses = React.addons.classSet({
+        "fx-embedded-call-detail": true,
+        "hide": !this.props.showIcons
+      });
+
+      return (
+        <div className="fx-embedded-call-identifier">
+          <div className="fx-embedded-call-identifier-avatar fx-embedded-call-identifier-item"/>
+          <div className="fx-embedded-call-identifier-info fx-embedded-call-identifier-item">
+            <div className="fx-embedded-call-identifier-text overflow-text-ellipsis>
+              {this.props.peerIdentifier}
+            </div>
+            <div className={callDetailClasses}>
+              <span className="fx-embedded-tiny-audio-icon"></span>
+              <span className={iconVideoClasses}></span>
+              <span className="fx-embedded-conversation-timestamp">
+                {this.formatCreationDate()}
+              </span>
+            </div>
+          </div>
+        </div>
+      );
+    }
+  });
+
+  /**
    * Displays details of the incoming/outgoing conversation
    * (name, link, audio/video type etc).
    *
    * Allows the view to be extended with different buttons and progress
    * via children properties.
    */
   var ConversationDetailView = React.createClass({
     propTypes: {
@@ -46,17 +113,19 @@ loop.conversationViews = (function(mozL1
       } else {
         contactName = this._getPreferredEmail(this.props.contact).value;
       }
 
       document.title = contactName;
 
       return (
         <div className="call-window">
-          <h2>{contactName}</h2>
+          <CallIdentifierView
+            peerIdentifier={contactName}
+            showIcons={false} />
           <div>{this.props.children}</div>
         </div>
       );
     }
   });
 
   /**
    * View for pending conversations. Displays a cancel button and appropriate
@@ -377,15 +446,16 @@ loop.conversationViews = (function(mozL1
           />)
         }
       }
     },
   });
 
   return {
     PendingConversationView: PendingConversationView,
+    CallIdentifierView: CallIdentifierView,
     ConversationDetailView: ConversationDetailView,
     CallFailedView: CallFailedView,
     OngoingConversationView: OngoingConversationView,
     OutgoingConversationView: OutgoingConversationView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -287,41 +287,34 @@ loop.panel = (function(_, mozL10n) {
     /**
      * Provided by DocumentVisibilityMixin. Schedules retrieval of a new call
      * URL everytime the panel is reopened.
      */
     onDocumentVisible: function() {
       this._fetchCallUrl();
     },
 
-    /**
-    * Returns a random 5 character string used to identify
-    * the conversation.
-    * XXX this will go away once the backend changes
-    */
-    conversationIdentifier: function() {
-      return Math.random().toString(36).substring(5);
-    },
-
     componentDidMount: function() {
       // If we've already got a callURL, don't bother requesting a new one.
       // As of this writing, only used for visual testing in the UI showcase.
       if (this.state.callUrl.length) {
         return;
       }
 
       this._fetchCallUrl();
     },
 
     /**
      * Fetches a call URL.
      */
     _fetchCallUrl: function() {
       this.setState({pending: true});
-      this.props.client.requestCallUrl(this.conversationIdentifier(),
+      // XXX This is an empty string as a conversation identifier. Bug 1015938 implements
+      // a user-set string.
+      this.props.client.requestCallUrl("",
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
       if (err) {
         if (err.code != 401) {
           // 401 errors are already handled in hawkRequest and show an error
           // message about the session.
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -287,41 +287,34 @@ loop.panel = (function(_, mozL10n) {
     /**
      * Provided by DocumentVisibilityMixin. Schedules retrieval of a new call
      * URL everytime the panel is reopened.
      */
     onDocumentVisible: function() {
       this._fetchCallUrl();
     },
 
-    /**
-    * Returns a random 5 character string used to identify
-    * the conversation.
-    * XXX this will go away once the backend changes
-    */
-    conversationIdentifier: function() {
-      return Math.random().toString(36).substring(5);
-    },
-
     componentDidMount: function() {
       // If we've already got a callURL, don't bother requesting a new one.
       // As of this writing, only used for visual testing in the UI showcase.
       if (this.state.callUrl.length) {
         return;
       }
 
       this._fetchCallUrl();
     },
 
     /**
      * Fetches a call URL.
      */
     _fetchCallUrl: function() {
       this.setState({pending: true});
-      this.props.client.requestCallUrl(this.conversationIdentifier(),
+      // XXX This is an empty string as a conversation identifier. Bug 1015938 implements
+      // a user-set string.
+      this.props.client.requestCallUrl("",
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
       if (err) {
         if (err.code != 401) {
           // 401 errors are already handled in hawkRequest and show an error
           // message about the session.
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -89,22 +89,24 @@
   vertical-align: top;
   width: .8rem;
   height: .8rem;
   background-repeat: no-repeat;
   cursor: pointer;
 }
 
 .fx-embedded-btn-icon-video,
-.fx-embedded-btn-video-small {
+.fx-embedded-btn-video-small,
+.fx-embedded-tiny-video-icon {
   background-image: url("../img/video-inverse-14x14.png");
 }
 
 .fx-embedded-btn-icon-audio,
-.fx-embedded-btn-audio-small {
+.fx-embedded-btn-audio-small,
+.fx-embedded-tiny-audio-icon {
   background-image: url("../img/audio-inverse-14x14.png");
 }
 
 .fx-embedded-btn-audio-small,
 .fx-embedded-btn-video-small {
   width: 26px;
   height: 26px;
   border-left: 1px solid rgba(255,255,255,.4);
@@ -479,16 +481,84 @@
   background-size: contain;
   background-position: center;
 }
 
 .fx-embedded .media.nested {
   min-height: 200px;
 }
 
+.fx-embedded-call-identifier {
+  display: inline;
+  width: 100%;
+  padding: 1.2em;
+}
+
+.fx-embedded-call-identifier-item {
+  height: 50px;
+}
+
+.fx-embedded-call-identifier-avatar {
+  max-width: 50px;
+  min-width: 50px;
+  background: #ccc;
+  border-radius: 50%;
+  background-image: url("../img/audio-call-avatar.svg");
+  background-repeat: no-repeat;
+  background-color: #4ba6e7;
+  background-size: contain;
+  overflow: hidden;
+  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
+  float: left;
+  -moz-margin-end: 1em;
+}
+
+.fx-embedded-call-identifier-text {
+  font-weight: bold;
+}
+
+.fx-embedded-call-identifier-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  -moz-margin-start: 1em;
+}
+
+.fx-embedded-conversation-timestamp {
+  font-size: .6rem;
+  line-height: 17px;
+  display: inline-block;
+  vertical-align: top;
+}
+
+.fx-embedded-call-detail {
+  padding-top: 1.2em;
+}
+
+.fx-embedded-tiny-video-icon {
+  margin: 0 0.8em;
+}
+
+.fx-embedded-tiny-audio-icon,
+.fx-embedded-tiny-video-icon {
+  width: 18px;
+  height: 18px;
+  background-size: 12px 12px;
+  background-color: #4ba6e7;
+  display: inline-block;
+  background-repeat: no-repeat;
+  background-position: center;
+  border-radius: 50%;
+}
+
+  .fx-embedded-tiny-video-icon.muted {
+    background-color: rgba(0,0,0,.2)
+  }
+
 @media screen and (min-width:640px) {
 
   /* Force full height on all parents up to the video elements
    * this way we can ensure the aspect ratio and use height 100%
    * on the video element
    * */
   html, body, #main,
   .video-layout-wrapper,
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -27,16 +27,17 @@ loop.shared.models = (function(l10n) {
       websocketToken: undefined,   // The token to use for websocket auth, this is
                                    // stored as a hex string which is what the server
                                    // requires.
       callType:     undefined,     // The type of incoming call selected by
                                    // other peer ("audio" or "audio-video")
       selectedCallType: "audio-video", // The selected type for the call that was
                                        // initiated ("audio" or "audio-video")
       callToken:    undefined,     // Incoming call token.
+      callUrl:      undefined,     // Incoming call url
                                    // Used for blocking a call url
       subscribedStream: false,     // Used to indicate that a stream has been
                                    // subscribed to
       publishedStream: false       // Used to indicate that a stream has been
                                    // published
     },
 
     /**
@@ -137,25 +138,28 @@ loop.shared.models = (function(l10n) {
     /**
      * Sets session information about the incoming call.
      *
      * @param {Object} sessionData Conversation session information.
      */
     setIncomingSessionData: function(sessionData) {
       // Explicit property assignment to prevent later "surprises"
       this.set({
-        sessionId:      sessionData.sessionId,
-        sessionToken:   sessionData.sessionToken,
-        sessionType:    sessionData.sessionType,
-        apiKey:         sessionData.apiKey,
-        callId:         sessionData.callId,
-        progressURL:    sessionData.progressURL,
-        websocketToken: sessionData.websocketToken.toString(16),
-        callType:       sessionData.callType || "audio-video",
-        callToken:      sessionData.callToken
+        sessionId:       sessionData.sessionId,
+        sessionToken:    sessionData.sessionToken,
+        sessionType:     sessionData.sessionType,
+        apiKey:          sessionData.apiKey,
+        callId:          sessionData.callId,
+        callerId:        sessionData.callerId,
+        urlCreationDate: sessionData.urlCreationDate,
+        progressURL:     sessionData.progressURL,
+        websocketToken:  sessionData.websocketToken.toString(16),
+        callType:        sessionData.callType || "audio-video",
+        callToken:       sessionData.callToken,
+        callUrl:         sessionData.callUrl
       });
     },
 
     /**
      * Starts a SDK session and subscribe to call events.
      */
     startSession: function() {
       if (!this.isSessionReady()) {
@@ -194,16 +198,33 @@ loop.shared.models = (function(l10n) {
       }
       if (callType === "outgoing") {
         return this.get("selectedCallType") === "audio-video";
       }
       return undefined;
     },
 
     /**
+     * Used to remove the scheme from a url.
+     */
+    _removeScheme: function(url) {
+      if (!url) {
+        return "";
+      }
+      return url.replace(/^https?:\/\//, "");
+    },
+
+    /**
+     * Returns a conversation identifier for the incoming call view
+     */
+    getCallIdentifier: function() {
+      return this.get("callerId") || this._removeScheme(this.get("callUrl"));
+    },
+
+    /**
      * Publishes a local stream.
      *
      * @param {Publisher} publisher The publisher object to publish
      *                              to the session.
      */
     publish: function(publisher) {
       this.session.publish(publisher);
       this.set("publishedStream", true);
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -13,16 +13,28 @@ loop.shared.utils = (function() {
    * Call types used for determining if a call is audio/video or audio-only.
    */
   var CALL_TYPES = {
     AUDIO_VIDEO: "audio-video",
     AUDIO_ONLY: "audio"
   };
 
   /**
+   * 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));
+    var options = {year: "numeric", month: "long", day: "numeric"};
+    return date.toLocaleDateString(navigator.language, options);
+  }
+
+  /**
    * Used for adding different styles to the panel
    * @returns {String} Corresponds to the client platform
    * */
   function getTargetPlatform() {
     var platform="unknown_platform";
 
     if (navigator.platform.indexOf("Win") !== -1) {
       platform = "windows";
@@ -82,12 +94,13 @@ loop.shared.utils = (function() {
     locationHash: function() {
       return window.location.hash;
     }
   };
 
   return {
     CALL_TYPES: CALL_TYPES,
     Helper: Helper,
+    formatDate: formatDate,
     getTargetPlatform: getTargetPlatform,
     getBoolPreference: getBoolPreference
   };
 })();
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -437,20 +437,19 @@ loop.webapp = (function($, _, OT, mozL10
         this.setState({disableCallButton: true});
       }.bind(this);
     },
 
     _setConversationTimestamp: function(err, callUrlInfo) {
       if (err) {
         this.props.notifications.errorL10n("unable_retrieve_call_info");
       } else {
-        var date = (new Date(callUrlInfo.urlCreationDate * 1000));
-        var options = {year: "numeric", month: "long", day: "numeric"};
-        var timestamp = date.toLocaleDateString(navigator.language, options);
-        this.setState({urlCreationDateString: timestamp});
+        this.setState({
+          urlCreationDateString: sharedUtils.formatDate(callUrlInfo.urlCreationDate)
+        });
       }
     },
 
     render: function() {
       var tosLinkName = mozL10n.get("terms_of_use_link_text");
       var privacyNoticeName = mozL10n.get("privacy_notice_link_text");
 
       var tosHTML = mozL10n.get("legal_text_and_links", {
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -437,20 +437,19 @@ loop.webapp = (function($, _, OT, mozL10
         this.setState({disableCallButton: true});
       }.bind(this);
     },
 
     _setConversationTimestamp: function(err, callUrlInfo) {
       if (err) {
         this.props.notifications.errorL10n("unable_retrieve_call_info");
       } else {
-        var date = (new Date(callUrlInfo.urlCreationDate * 1000));
-        var options = {year: "numeric", month: "long", day: "numeric"};
-        var timestamp = date.toLocaleDateString(navigator.language, options);
-        this.setState({urlCreationDateString: timestamp});
+        this.setState({
+          urlCreationDateString: sharedUtils.formatDate(callUrlInfo.urlCreationDate)
+        });
       }
     },
 
     render: function() {
       var tosLinkName = mozL10n.get("terms_of_use_link_text");
       var privacyNoticeName = mozL10n.get("privacy_notice_link_text");
 
       var tosHTML = mozL10n.get("legal_text_and_links", {
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -30,45 +30,97 @@ describe("loop.conversationViews", funct
   });
 
   afterEach(function() {
     document.title = oldTitle;
     view = undefined;
     sandbox.restore();
   });
 
+  describe("CallIdentifierView", function() {
+    function mountTestComponent(props) {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.CallIdentifierView(props));
+    }
+
+    it("should set display the peer identifer", function() {
+      view = mountTestComponent({
+        showIcons: false,
+        peerIdentifier: "mrssmith"
+      });
+
+      expect(TestUtils.findRenderedDOMComponentWithClass(
+        view, "fx-embedded-call-identifier-text").props.children).eql("mrssmith");
+    });
+
+    it("should not display the icons if showIcons is false", function() {
+      view = mountTestComponent({
+        showIcons: false,
+        peerIdentifier: "mrssmith"
+      });
+
+      expect(TestUtils.findRenderedDOMComponentWithClass(
+        view, "fx-embedded-call-detail").props.className).to.contain("hide");
+    });
+
+    it("should display the icons if showIcons is true", function() {
+      view = mountTestComponent({
+        showIcons: true,
+        peerIdentifier: "mrssmith"
+      });
+
+      expect(TestUtils.findRenderedDOMComponentWithClass(
+        view, "fx-embedded-call-detail").props.className).to.not.contain("hide");
+    });
+
+    it("should display the url timestamp", function() {
+      sandbox.stub(loop.shared.utils, "formatDate").returns(("October 9, 2014"));
+
+      view = mountTestComponent({
+        showIcons: true,
+        peerIdentifier: "mrssmith",
+        urlCreationDate: (new Date() / 1000).toString()
+      });
+
+      expect(TestUtils.findRenderedDOMComponentWithClass(
+        view, "fx-embedded-conversation-timestamp").props.children).eql("(October 9, 2014)");
+    });
+
+    it("should show video as muted if video is false", function() {
+      view = mountTestComponent({
+        showIcons: true,
+        peerIdentifier: "mrssmith",
+        video: false
+      });
+
+      expect(TestUtils.findRenderedDOMComponentWithClass(
+        view, "fx-embedded-tiny-video-icon").props.className).to.contain("muted");
+    });
+  });
+
   describe("ConversationDetailView", function() {
     function mountTestComponent(props) {
       return TestUtils.renderIntoDocument(
         loop.conversationViews.ConversationDetailView(props));
     }
 
     it("should set the document title to the calledId", function() {
       mountTestComponent({contact: contact});
 
       expect(document.title).eql("mrsmith");
     });
 
-    it("should set display the calledId", function() {
-      view = mountTestComponent({contact: contact});
-
-      expect(TestUtils.findRenderedDOMComponentWithTag(
-        view, "h2").props.children).eql("mrsmith");
-    });
-
     it("should fallback to the email if the contact name is not defined",
       function() {
         delete contact.name;
 
-        view = mountTestComponent({contact: contact});
+        mountTestComponent({contact: contact});
 
-        expect(TestUtils.findRenderedDOMComponentWithTag(
-          view, "h2").props.children).eql("fakeEmail");
-      }
-    );
+        expect(document.title).eql("fakeEmail");
+      });
   });
 
   describe("PendingConversationView", function() {
     function mountTestComponent(props) {
       return TestUtils.renderIntoDocument(
         loop.conversationViews.PendingConversationView(props));
     }
 
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -691,17 +691,19 @@ describe("loop.conversation", function()
       });
     });
   });
 
   describe("IncomingCallView", function() {
     var view, model;
 
     beforeEach(function() {
-      var Model = Backbone.Model.extend({});
+      var Model = Backbone.Model.extend({
+        getCallIdentifier: function() {return "fakeId";}
+      });
       model = new Model();
       sandbox.spy(model, "trigger");
       sandbox.stub(model, "set");
 
       view = TestUtils.renderIntoDocument(loop.conversation.IncomingCallView({
         model: model,
         video: true
       }));
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -22,17 +22,19 @@ describe("loop.shared.models", function(
       requests.push(xhr);
     };
     fakeSessionData = {
       sessionId:      "sessionId",
       sessionToken:   "sessionToken",
       apiKey:         "apiKey",
       callType:       "callType",
       websocketToken: 123,
-      callToken:    "callToken"
+      callToken:      "callToken",
+      callUrl:        "http://invalid/callToken",
+      callerId:       "mrssmith"
     };
     fakeSession = _.extend({
       connect: function () {},
       endSession: sandbox.stub(),
       set: sandbox.stub(),
       disconnect: sandbox.spy(),
       unpublish: sandbox.spy()
     }, Backbone.Events);
@@ -355,16 +357,38 @@ describe("loop.shared.models", function(
         });
 
         it("should return true for outgoing callType", function() {
           model.set("selectedCallType", "audio-video");
 
           expect(model.hasVideoStream("outgoing")).to.eql(true);
         });
       });
+
+      describe("#getCallIdentifier", function() {
+        var model;
+
+        beforeEach(function() {
+          model = new sharedModels.ConversationModel(fakeSessionData, {
+            sdk: fakeSDK
+          });
+          model.startSession();
+        });
+
+        it("should return the callerId", function() {
+          expect(model.getCallIdentifier()).to.eql("mrssmith");
+        });
+
+        it("should return the shorted callUrl if the callerId does not exist",
+          function() {
+            model.set({"callerId": ""});
+
+            expect(model.getCallIdentifier()).to.eql("invalid/callToken");
+          });
+      });
     });
   });
 
   describe("NotificationCollection", function() {
     var collection, notifData, testNotif;
 
     beforeEach(function() {
       collection = new sharedModels.NotificationCollection();
--- a/browser/components/loop/test/shared/utils_test.js
+++ b/browser/components/loop/test/shared/utils_test.js
@@ -83,16 +83,36 @@ describe("loop.shared.utils", function()
 
         it("shouldn't detect FirefoxOS on non mobile platform", function() {
           expect(helper.isFirefoxOS("whatever")).eql(false);
         });
       });
     });
   });
 
+  describe("#formatDate", function() {
+    beforeEach(function() {
+      sandbox.stub(Date.prototype, "toLocaleDateString").returns("fake result");
+    });
+
+    it("should call toLocaleDateString with arguments", function() {
+      sharedUtils.formatDate(1000);
+
+      sinon.assert.calledOnce(Date.prototype.toLocaleDateString);
+      sinon.assert.calledWithExactly(Date.prototype.toLocaleDateString,
+        navigator.language,
+        {year: "numeric", month: "long", day: "numeric"}
+      );
+    });
+
+    it("should return the formatted string", function() {
+      expect(sharedUtils.formatDate(1000)).eql("fake result");
+    });
+  });
+
   describe("#getBoolPreference", function() {
     afterEach(function() {
       navigator.mozLoop = undefined;
       localStorage.removeItem("test.true");
     });
 
     describe("mozLoop set", function() {
       beforeEach(function() {
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -67,17 +67,20 @@
 
   var mockClient = {
     requestCallUrl: noop,
     requestCallUrlInfo: noop
   };
 
   var mockSDK = {};
 
-  var mockConversationModel = new loop.shared.models.ConversationModel({}, {
+  var mockConversationModel = new loop.shared.models.ConversationModel({
+    callerId: "Mrs Jones",
+    urlCreationDate: (new Date() / 1000).toString()
+  }, {
     sdk: mockSDK
   });
   mockConversationModel.startSession = noop;
 
   var mockWebSocket = new loop.CallConnectionWebSocket({
     url: "fake",
     callId: "fakeId",
     websocketToken: "fakeToken"
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -67,17 +67,20 @@
 
   var mockClient = {
     requestCallUrl: noop,
     requestCallUrlInfo: noop
   };
 
   var mockSDK = {};
 
-  var mockConversationModel = new loop.shared.models.ConversationModel({}, {
+  var mockConversationModel = new loop.shared.models.ConversationModel({
+    callerId: "Mrs Jones",
+    urlCreationDate: (new Date() / 1000).toString()
+  }, {
     sdk: mockSDK
   });
   mockConversationModel.startSession = noop;
 
   var mockWebSocket = new loop.CallConnectionWebSocket({
     url: "fake",
     callId: "fakeId",
     websocketToken: "fakeToken"