Bug 1048162 Part 1 - Add an 'Email Link' button to Loop desktop failed call view. r=Standard8 a=lmandel
authorNicolas Perriault <nperriault@gmail.com>
Thu, 16 Oct 2014 18:58:59 +0100
changeset 225733 f705ffd06218
parent 225732 5ad9f4e96214
child 225734 191b3ce44bea
push id3995
push userrjesup@wgate.com
push date2014-10-20 00:58 +0000
treeherdermozilla-beta@8c42ccaf8aa1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8, lmandel
bugs1048162
milestone34.0
Bug 1048162 Part 1 - Add an 'Email Link' button to Loop desktop failed call view. r=Standard8 a=lmandel
browser/components/loop/MozLoopAPI.jsm
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/actions.js
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/content/shared/js/utils.js
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/panel_test.js
browser/components/loop/test/shared/conversationStore_test.js
browser/components/loop/test/shared/utils_test.js
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -596,25 +596,28 @@ function injectLoopAPI(targetWindow) {
         }
         return appVersionInfo;
       }
     },
 
     /**
      * Composes an email via the external protocol service.
      *
-     * @param {String} subject Subject of the email to send
-     * @param {String} body    Body message of the email to send
+     * @param {String} subject   Subject of the email to send
+     * @param {String} body      Body message of the email to send
+     * @param {String} recipient Recipient email address (optional)
      */
     composeEmail: {
       enumerable: true,
       writable: true,
-      value: function(subject, body) {
-        let mailtoURL = "mailto:?subject=" + encodeURIComponent(subject) + "&" +
-                        "body=" + encodeURIComponent(body);
+      value: function(subject, body, recipient) {
+        recipient = recipient || "";
+        let mailtoURL = "mailto:" + encodeURIComponent(recipient) +
+                        "?subject=" + encodeURIComponent(subject) +
+                        "&body=" + encodeURIComponent(body);
         extProtocolSvc.loadURI(CommonUtils.makeURI(mailtoURL));
       }
     },
 
     /**
      * Adds a value to a telemetry histogram.
      *
      * @param  {string}  histogramId Name of the telemetry histogram to update.
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -7,18 +7,30 @@
 /* global loop:true, React */
 
 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 sharedUtils = loop.shared.utils;
   var sharedViews = loop.shared.views;
 
+  // This duplicates a similar function in contacts.jsx that isn't used in the
+  // conversation window. If we get too many of these, we might want to consider
+  // finding a logical place for them to be shared.
+  function _getPreferredEmail(contact) {
+    // A contact may not contain email addresses, but only a phone number.
+    if (!contact.email || contact.email.length === 0) {
+      return { value: "" };
+    }
+    return contact.email.find(e => e.pref) || contact.email[0];
+  }
+
   /**
    * 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,
@@ -88,35 +100,24 @@ loop.conversationViews = (function(mozL1
    * Allows the view to be extended with different buttons and progress
    * via children properties.
    */
   var ConversationDetailView = React.createClass({displayName: 'ConversationDetailView',
     propTypes: {
       contact: React.PropTypes.object
     },
 
-    // This duplicates a similar function in contacts.jsx that isn't used in the
-    // conversation window. If we get too many of these, we might want to consider
-    // finding a logical place for them to be shared.
-    _getPreferredEmail: function(contact) {
-      // A contact may not contain email addresses, but only a phone number.
-      if (!contact.email || contact.email.length == 0) {
-        return { value: "" };
-      }
-      return contact.email.find(e => e.pref) || contact.email[0];
-    },
-
     render: function() {
       var contactName;
 
       if (this.props.contact.name &&
           this.props.contact.name[0]) {
         contactName = this.props.contact.name[0];
       } else {
-        contactName = this._getPreferredEmail(this.props.contact).value;
+        contactName = _getPreferredEmail(this.props.contact).value;
       }
 
       document.title = contactName;
 
       return (
         React.DOM.div({className: "call-window"}, 
           CallIdentifierView({
             peerIdentifier: contactName, 
@@ -182,47 +183,80 @@ loop.conversationViews = (function(mozL1
       );
     }
   });
 
   /**
    * Call failed view. Displayed when a call fails.
    */
   var CallFailedView = React.createClass({displayName: 'CallFailedView',
+    mixins: [Backbone.Events],
+
     propTypes: {
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      store: React.PropTypes.instanceOf(
+        loop.store.ConversationStore).isRequired,
+      contact: React.PropTypes.object.isRequired
+    },
+
+    getInitialState: function() {
+      return {emailLinkButtonDisabled: false};
+    },
+
+    componentDidMount: function() {
+      this.listenTo(this.props.store, "change:emailLink",
+                    this._onEmailLinkReceived);
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.store);
+    },
+
+    _onEmailLinkReceived: function() {
+      var emailLink = this.props.store.get("emailLink");
+      var contactEmail = _getPreferredEmail(this.props.contact).value;
+      sharedUtils.composeCallUrlEmail(emailLink, contactEmail);
+      window.close();
     },
 
     retryCall: function() {
       this.props.dispatcher.dispatch(new sharedActions.RetryCall());
     },
 
     cancelCall: function() {
       this.props.dispatcher.dispatch(new sharedActions.CancelCall());
     },
 
+    emailLink: function() {
+      this.setState({emailLinkButtonDisabled: true});
+
+      this.props.dispatcher.dispatch(new sharedActions.FetchEmailLink());
+    },
+
     render: function() {
       return (
         React.DOM.div({className: "call-window"}, 
           React.DOM.h2(null, mozL10n.get("generic_failure_title")), 
 
-          React.DOM.p({className: "btn-label"}, mozL10n.get("generic_failure_no_reason2")), 
+          React.DOM.p({className: "btn-label"}, mozL10n.get("generic_failure_with_reason2")), 
 
           React.DOM.div({className: "btn-group call-action-group"}, 
-            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
-              React.DOM.button({className: "btn btn-accept btn-retry", 
-                      onClick: this.retryCall}, 
-                mozL10n.get("retry_call_button")
-              ), 
-            React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
-              React.DOM.button({className: "btn btn-cancel", 
-                      onClick: this.cancelCall}, 
-                mozL10n.get("cancel_button")
-              ), 
-            React.DOM.div({className: "fx-embedded-call-button-spacer"})
+            React.DOM.button({className: "btn btn-cancel", 
+                    onClick: this.cancelCall}, 
+              mozL10n.get("cancel_button")
+            ), 
+            React.DOM.button({className: "btn btn-info btn-retry", 
+                    onClick: this.retryCall}, 
+              mozL10n.get("retry_call_button")
+            ), 
+            React.DOM.button({className: "btn btn-info btn-email", 
+                    onClick: this.emailLink, 
+                    disabled: this.state.emailLinkButtonDisabled}, 
+              mozL10n.get("share_button")
+            )
           )
         )
       );
     }
   });
 
   var OngoingConversationView = React.createClass({displayName: 'OngoingConversationView',
     propTypes: {
@@ -420,17 +454,19 @@ loop.conversationViews = (function(mozL1
     render: function() {
       switch (this.state.callState) {
         case CALL_STATES.CLOSE: {
           this._closeWindow();
           return null;
         }
         case CALL_STATES.TERMINATED: {
           return (CallFailedView({
-            dispatcher: this.props.dispatcher}
+            dispatcher: this.props.dispatcher, 
+            store: this.props.store, 
+            contact: this.state.contact}
           ));
         }
         case CALL_STATES.ONGOING: {
           return (OngoingConversationView({
             dispatcher: this.props.dispatcher, 
             video: {enabled: !this.state.videoMuted}, 
             audio: {enabled: !this.state.audioMuted}}
             )
@@ -440,17 +476,17 @@ loop.conversationViews = (function(mozL1
           return this._renderFeedbackView();
         }
         default: {
           return (PendingConversationView({
             dispatcher: this.props.dispatcher, 
             callState: this.state.callState, 
             contact: this.state.contact, 
             enableCancelButton: this._isCancellable()}
-          ))
+          ));
         }
       }
     },
   });
 
   return {
     PendingConversationView: PendingConversationView,
     CallIdentifierView: CallIdentifierView,
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -7,18 +7,30 @@
 /* global loop:true, React */
 
 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 sharedUtils = loop.shared.utils;
   var sharedViews = loop.shared.views;
 
+  // This duplicates a similar function in contacts.jsx that isn't used in the
+  // conversation window. If we get too many of these, we might want to consider
+  // finding a logical place for them to be shared.
+  function _getPreferredEmail(contact) {
+    // A contact may not contain email addresses, but only a phone number.
+    if (!contact.email || contact.email.length === 0) {
+      return { value: "" };
+    }
+    return contact.email.find(e => e.pref) || contact.email[0];
+  }
+
   /**
    * 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,
@@ -88,35 +100,24 @@ loop.conversationViews = (function(mozL1
    * Allows the view to be extended with different buttons and progress
    * via children properties.
    */
   var ConversationDetailView = React.createClass({
     propTypes: {
       contact: React.PropTypes.object
     },
 
-    // This duplicates a similar function in contacts.jsx that isn't used in the
-    // conversation window. If we get too many of these, we might want to consider
-    // finding a logical place for them to be shared.
-    _getPreferredEmail: function(contact) {
-      // A contact may not contain email addresses, but only a phone number.
-      if (!contact.email || contact.email.length == 0) {
-        return { value: "" };
-      }
-      return contact.email.find(e => e.pref) || contact.email[0];
-    },
-
     render: function() {
       var contactName;
 
       if (this.props.contact.name &&
           this.props.contact.name[0]) {
         contactName = this.props.contact.name[0];
       } else {
-        contactName = this._getPreferredEmail(this.props.contact).value;
+        contactName = _getPreferredEmail(this.props.contact).value;
       }
 
       document.title = contactName;
 
       return (
         <div className="call-window">
           <CallIdentifierView
             peerIdentifier={contactName}
@@ -182,47 +183,80 @@ loop.conversationViews = (function(mozL1
       );
     }
   });
 
   /**
    * Call failed view. Displayed when a call fails.
    */
   var CallFailedView = React.createClass({
+    mixins: [Backbone.Events],
+
     propTypes: {
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      store: React.PropTypes.instanceOf(
+        loop.store.ConversationStore).isRequired,
+      contact: React.PropTypes.object.isRequired
+    },
+
+    getInitialState: function() {
+      return {emailLinkButtonDisabled: false};
+    },
+
+    componentDidMount: function() {
+      this.listenTo(this.props.store, "change:emailLink",
+                    this._onEmailLinkReceived);
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.store);
+    },
+
+    _onEmailLinkReceived: function() {
+      var emailLink = this.props.store.get("emailLink");
+      var contactEmail = _getPreferredEmail(this.props.contact).value;
+      sharedUtils.composeCallUrlEmail(emailLink, contactEmail);
+      window.close();
     },
 
     retryCall: function() {
       this.props.dispatcher.dispatch(new sharedActions.RetryCall());
     },
 
     cancelCall: function() {
       this.props.dispatcher.dispatch(new sharedActions.CancelCall());
     },
 
+    emailLink: function() {
+      this.setState({emailLinkButtonDisabled: true});
+
+      this.props.dispatcher.dispatch(new sharedActions.FetchEmailLink());
+    },
+
     render: function() {
       return (
         <div className="call-window">
           <h2>{mozL10n.get("generic_failure_title")}</h2>
 
-          <p className="btn-label">{mozL10n.get("generic_failure_no_reason2")}</p>
+          <p className="btn-label">{mozL10n.get("generic_failure_with_reason2")}</p>
 
           <div className="btn-group call-action-group">
-            <div className="fx-embedded-call-button-spacer"></div>
-              <button className="btn btn-accept btn-retry"
-                      onClick={this.retryCall}>
-                {mozL10n.get("retry_call_button")}
-              </button>
-            <div className="fx-embedded-call-button-spacer"></div>
-              <button className="btn btn-cancel"
-                      onClick={this.cancelCall}>
-                {mozL10n.get("cancel_button")}
-              </button>
-            <div className="fx-embedded-call-button-spacer"></div>
+            <button className="btn btn-cancel"
+                    onClick={this.cancelCall}>
+              {mozL10n.get("cancel_button")}
+            </button>
+            <button className="btn btn-info btn-retry"
+                    onClick={this.retryCall}>
+              {mozL10n.get("retry_call_button")}
+            </button>
+            <button className="btn btn-info btn-email"
+                    onClick={this.emailLink}
+                    disabled={this.state.emailLinkButtonDisabled}>
+              {mozL10n.get("share_button")}
+            </button>
           </div>
         </div>
       );
     }
   });
 
   var OngoingConversationView = React.createClass({
     propTypes: {
@@ -421,16 +455,18 @@ loop.conversationViews = (function(mozL1
       switch (this.state.callState) {
         case CALL_STATES.CLOSE: {
           this._closeWindow();
           return null;
         }
         case CALL_STATES.TERMINATED: {
           return (<CallFailedView
             dispatcher={this.props.dispatcher}
+            store={this.props.store}
+            contact={this.state.contact}
           />);
         }
         case CALL_STATES.ONGOING: {
           return (<OngoingConversationView
             dispatcher={this.props.dispatcher}
             video={{enabled: !this.state.videoMuted}}
             audio={{enabled: !this.state.audioMuted}}
             />
@@ -440,17 +476,17 @@ loop.conversationViews = (function(mozL1
           return this._renderFeedbackView();
         }
         default: {
           return (<PendingConversationView
             dispatcher={this.props.dispatcher}
             callState={this.state.callState}
             contact={this.state.contact}
             enableCancelButton={this._isCancellable()}
-          />)
+          />);
         }
       }
     },
   });
 
   return {
     PendingConversationView: PendingConversationView,
     CallIdentifierView: CallIdentifierView,
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -9,16 +9,17 @@
 
 var loop = loop || {};
 loop.panel = (function(_, mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedModels = loop.shared.models;
   var sharedMixins = loop.shared.mixins;
+  var sharedUtils = loop.shared.utils;
   var Button = sharedViews.Button;
   var ButtonGroup = sharedViews.ButtonGroup;
   var ContactsList = loop.contacts.ContactsList;
   var ContactDetailsForm = loop.contacts.ContactDetailsForm;
   var __ = mozL10n.get; // aliasing translation function as __ for concision
 
   var TabView = React.createClass({displayName: 'TabView',
     getInitialState: function() {
@@ -341,18 +342,17 @@ loop.panel = (function(_, mozL10n) {
           this.setState(this.getInitialState());
         }
       }
     },
 
     handleEmailButtonClick: function(event) {
       this.handleLinkExfiltration(event);
 
-      navigator.mozLoop.composeEmail(__("share_email_subject3"),
-        __("share_email_body3", { callUrl: this.state.callUrl }));
+      sharedUtils.composeCallUrlEmail(this.state.callUrl);
     },
 
     handleCopyButtonClick: function(event) {
       this.handleLinkExfiltration(event);
       // XXX the mozLoop object should be passed as a prop, to ease testing and
       //     using a fake implementation in UI components showcase.
       navigator.mozLoop.copyString(this.state.callUrl);
       this.setState({copied: true});
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -9,16 +9,17 @@
 
 var loop = loop || {};
 loop.panel = (function(_, mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedModels = loop.shared.models;
   var sharedMixins = loop.shared.mixins;
+  var sharedUtils = loop.shared.utils;
   var Button = sharedViews.Button;
   var ButtonGroup = sharedViews.ButtonGroup;
   var ContactsList = loop.contacts.ContactsList;
   var ContactDetailsForm = loop.contacts.ContactDetailsForm;
   var __ = mozL10n.get; // aliasing translation function as __ for concision
 
   var TabView = React.createClass({
     getInitialState: function() {
@@ -341,18 +342,17 @@ loop.panel = (function(_, mozL10n) {
           this.setState(this.getInitialState());
         }
       }
     },
 
     handleEmailButtonClick: function(event) {
       this.handleLinkExfiltration(event);
 
-      navigator.mozLoop.composeEmail(__("share_email_subject3"),
-        __("share_email_body3", { callUrl: this.state.callUrl }));
+      sharedUtils.composeCallUrlEmail(this.state.callUrl);
     },
 
     handleCopyButtonClick: function(event) {
       this.handleLinkExfiltration(event);
       // XXX the mozLoop object should be passed as a prop, to ease testing and
       //     using a fake implementation in UI components showcase.
       navigator.mozLoop.copyString(this.state.callUrl);
       this.setState({copied: true});
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -235,26 +235,31 @@
 .call-window {
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: space-between;
   min-height: 230px;
 }
 
+.call-window > .btn-label {
+  text-align: center;
+}
+
 .call-action-group {
   display: flex;
-  padding: 2.5em 0 0 0;
+  padding: 2.5em 4px 0 4px;
   width: 100%;
-  justify-content: space-around;
 }
 
 .call-action-group > .btn {
-  margin-left: .5em;
   height: 26px;
+  border-radius: 2px;
+  margin: 0 4px;
+  min-width: 64px;
 }
 
 .call-action-group .btn-group-chevron,
 .call-action-group .btn-group {
   width: 100%;
 }
 
 /* XXX Once we get the incoming call avatar, bug 1047435, the H2 should
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -26,16 +26,23 @@ loop.shared.actions = (function() {
   }
 
   Action.define = function(name, schema) {
     return Action.bind(null, name, schema);
   };
 
   return {
     /**
+     * Fetch a new call url from the server, intended to be sent over email when
+     * a contact can't be reached.
+     */
+    FetchEmailLink: Action.define("fetchEmailLink", {
+    }),
+
+    /**
      * Used to trigger gathering of initial call data.
      */
     GatherCallData: Action.define("gatherCallData", {
       // Specify the callId for an incoming call.
       callId: [String, null],
       outgoing: Boolean
     }),
 
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -121,17 +121,18 @@ loop.store = (function() {
         "connectionProgress",
         "gatherCallData",
         "connectCall",
         "hangupCall",
         "peerHungupCall",
         "cancelCall",
         "retryCall",
         "mediaConnected",
-        "setMute"
+        "setMute",
+        "fetchEmailLink"
       ]);
     },
 
     /**
      * Handles the connection failure action, setting the state to
      * terminated.
      *
      * @param {sharedActions.ConnectionFailure} actionData The action data.
@@ -299,16 +300,33 @@ loop.store = (function() {
      * @param {sharedActions.setMute} actionData The mute state for the stream type.
      */
     setMute: function(actionData) {
       var muteType = actionData.type + "Muted";
       this.set(muteType, !actionData.enabled);
     },
 
     /**
+     * Fetches a new call URL intended to be sent over email when a contact
+     * can't be reached.
+     */
+    fetchEmailLink: function() {
+      // XXX This is an empty string as a conversation identifier. Bug 1015938 implements
+      // a user-set string.
+      this.client.requestCallUrl("", function(err, callUrlData) {
+        if (err) {
+          // XXX better error reporting in the UI
+          console.error(err);
+          return;
+        }
+        this.set("emailLink", callUrlData.callUrl);
+      }.bind(this));
+    },
+
+    /**
      * Obtains the outgoing call data from the server and handles the
      * result.
      */
     _setupOutgoingCall: function() {
       var contactAddresses = [];
 
       this.get("contact").email.forEach(function(address) {
         contactAddresses.push(address.value);
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -1,17 +1,17 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true */
 
 var loop = loop || {};
 loop.shared = loop.shared || {};
-loop.shared.utils = (function() {
+loop.shared.utils = (function(mozL10n) {
   "use strict";
 
   /**
    * Call types used for determining if a call is audio/video or audio-only.
    */
   var CALL_TYPES = {
     AUDIO_VIDEO: "audio-video",
     AUDIO_ONLY: "audio"
@@ -91,16 +91,38 @@ loop.shared.utils = (function() {
       return this._iOSRegex.test(platform);
     },
 
     locationHash: function() {
       return window.location.hash;
     }
   };
 
+  /**
+   * Generates and opens a mailto: url with call URL information prefilled.
+   * Note: This only works for Desktop.
+   *
+   * @param  {String} callUrl   The call URL.
+   * @param  {String} recipient The recipient email address (optional).
+   */
+  function composeCallUrlEmail(callUrl, recipient) {
+    if (typeof navigator.mozLoop === "undefined") {
+      console.warn("composeCallUrlEmail isn't available for Loop standalone.");
+      return;
+    }
+    navigator.mozLoop.composeEmail(
+      mozL10n.get("share_email_subject3"),
+      mozL10n.get("share_email_body3", {
+        callUrl: callUrl
+      }),
+      recipient
+    );
+  }
+
   return {
     CALL_TYPES: CALL_TYPES,
     Helper: Helper,
+    composeCallUrlEmail: composeCallUrlEmail,
     formatDate: formatDate,
     getTargetPlatform: getTargetPlatform,
     getBoolPreference: getBoolPreference
   };
-})();
+})(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -1,14 +1,15 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 var expect = chai.expect;
 
 describe("loop.conversationViews", function () {
+  var sharedUtils = loop.shared.utils;
   var sandbox, oldTitle, view, dispatcher, contact;
 
   var CALL_STATES = loop.store.CALL_STATES;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
     oldTitle = document.title;
@@ -194,23 +195,35 @@ describe("loop.conversationViews", funct
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "cancelCall"));
       });
   });
 
   describe("CallFailedView", function() {
+    var store;
+
     function mountTestComponent(props) {
       return TestUtils.renderIntoDocument(
         loop.conversationViews.CallFailedView({
-          dispatcher: dispatcher
+          dispatcher: dispatcher,
+          store: store,
+          contact: {email: [{value: "test@test.tld"}]}
         }));
     }
 
+    beforeEach(function() {
+      store = new loop.store.ConversationStore({}, {
+        dispatcher: dispatcher,
+        client: {},
+        sdkDriver: {}
+      });
+    });
+
     it("should dispatch a retryCall action when the retry button is pressed",
       function() {
         view = mountTestComponent();
 
         var retryBtn = view.getDOMNode().querySelector('.btn-retry');
 
         React.addons.TestUtils.Simulate.click(retryBtn);
 
@@ -226,16 +239,58 @@ describe("loop.conversationViews", funct
         var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
 
         React.addons.TestUtils.Simulate.click(cancelBtn);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "cancelCall"));
       });
+
+    it("should dispatch a fetchEmailLink action when the cancel button is pressed",
+      function() {
+        view = mountTestComponent();
+
+        var emailLinkBtn = view.getDOMNode().querySelector('.btn-email');
+
+        React.addons.TestUtils.Simulate.click(emailLinkBtn);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "fetchEmailLink"));
+      });
+
+    it("should disable the email link button once the action is dispatched",
+      function() {
+        view = mountTestComponent();
+        var emailLinkBtn = view.getDOMNode().querySelector('.btn-email');
+        React.addons.TestUtils.Simulate.click(emailLinkBtn);
+
+        expect(view.getDOMNode().querySelector(".btn-email").disabled).eql(true);
+      });
+
+    it("should compose an email once the email link is received", function() {
+      var composeCallUrlEmail = sandbox.stub(sharedUtils, "composeCallUrlEmail");
+      view = mountTestComponent();
+      store.set("emailLink", "http://fake.invalid/");
+
+      sinon.assert.calledOnce(composeCallUrlEmail);
+      sinon.assert.calledWithExactly(composeCallUrlEmail,
+        "http://fake.invalid/", "test@test.tld");
+    });
+
+    it("should close the conversation window once the email link is received",
+      function() {
+        sandbox.stub(window, "close");
+        view = mountTestComponent();
+
+        store.set("emailLink", "http://fake.invalid/");
+
+        sinon.assert.calledOnce(window.close);
+      });
   });
 
   describe("OngoingConversationView", function() {
     function mountTestComponent(props) {
       return TestUtils.renderIntoDocument(
         loop.conversationViews.OngoingConversationView(props));
     }
 
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -2,16 +2,17 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /*jshint newcap:false*/
 /*global loop, sinon */
 
 var expect = chai.expect;
 var TestUtils = React.addons.TestUtils;
+var sharedUtils = loop.shared.utils;
 
 describe("loop.panel", function() {
   "use strict";
 
   var sandbox, notifications, fakeXHR, requests = [];
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
@@ -372,25 +373,28 @@ describe("loop.panel", function() {
       });
 
       it("should have 0 pending notifications", function() {
         expect(view.props.notifications.length).eql(0);
       });
 
       it("should display a share button for email", function() {
         fakeClient.requestCallUrl = sandbox.stub();
+        var composeCallUrlEmail = sandbox.stub(sharedUtils, "composeCallUrlEmail");
         var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
           notifications: notifications,
           client: fakeClient
         }));
         view.setState({pending: false, callUrl: "http://example.com"});
 
         TestUtils.findRenderedDOMComponentWithClass(view, "button-email");
         TestUtils.Simulate.click(view.getDOMNode().querySelector(".button-email"));
-        sinon.assert.calledOnce(navigator.mozLoop.composeEmail);
+
+        sinon.assert.calledOnce(composeCallUrlEmail);
+        sinon.assert.calledWithExactly(composeCallUrlEmail, "http://example.com");
       });
 
       it("should feature a copy button capable of copying the call url when clicked", function() {
         fakeClient.requestCallUrl = sandbox.stub();
         var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
           notifications: notifications,
           client: fakeClient
         }));
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -33,17 +33,18 @@ describe("loop.ConversationStore", funct
         type: "home",
         value: "fakeEmail",
         pref: true
       }]
     };
 
     dispatcher = new loop.Dispatcher();
     client = {
-      setupOutgoingCall: sinon.stub()
+      setupOutgoingCall: sinon.stub(),
+      requestCallUrl: sinon.stub()
     };
     sdkDriver = {
       connectSession: sinon.stub(),
       disconnectSession: sinon.stub()
     };
 
     wsCancelSpy = sinon.spy();
     wsCloseSpy = sinon.spy();
@@ -561,16 +562,38 @@ describe("loop.ConversationStore", funct
         type: "video",
         enabled: false
       }));
 
       expect(store.get("videoMuted")).eql(true);
     });
   });
 
+  describe("#fetchEmailLink", function() {
+    it("should request a new call url to the server", function() {
+      dispatcher.dispatch(new sharedActions.FetchEmailLink());
+
+      sinon.assert.calledOnce(client.requestCallUrl);
+      sinon.assert.calledWith(client.requestCallUrl, "");
+    });
+
+    it("should update the emailLink attribute when the new call url is received",
+      function() {
+        client.requestCallUrl = function(callId, cb) {
+          cb(null, {callUrl: "http://fake.invalid/"});
+        };
+        dispatcher.dispatch(new sharedActions.FetchEmailLink());
+
+        expect(store.get("emailLink")).eql("http://fake.invalid/");
+      });
+
+    // XXX bug 1048162 Part 2
+    it.skip("should trigger an error in case of failure");
+  });
+
   describe("Events", function() {
     describe("Websocket progress", function() {
       beforeEach(function() {
         dispatcher.dispatch(
           new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
         sandbox.stub(dispatcher, "dispatch");
       });
--- a/browser/components/loop/test/shared/utils_test.js
+++ b/browser/components/loop/test/shared/utils_test.js
@@ -13,16 +13,17 @@ describe("loop.shared.utils", function()
   var sandbox;
   var sharedUtils = loop.shared.utils;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
   });
 
   afterEach(function() {
+    navigator.mozLoop = undefined;
     sandbox.restore();
   });
 
   describe("Helper", function() {
     var helper;
 
     beforeEach(function() {
       helper = new sharedUtils.Helper();
@@ -105,17 +106,16 @@ describe("loop.shared.utils", function()
 
     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() {
         navigator.mozLoop = {
           getLoopBoolPref: function(prefName) {
             return prefName === "test.true";
@@ -137,9 +137,36 @@ describe("loop.shared.utils", function()
     describe("mozLoop not set", function() {
       it("should return the localStorage value", function() {
         localStorage.setItem("test.true", true);
 
         expect(sharedUtils.getBoolPreference("test.true")).eql(true);
       });
     });
   });
+
+  describe("#composeCallUrlEmail", function() {
+    var composeEmail;
+
+    beforeEach(function() {
+      // fake mozL10n
+      sandbox.stub(navigator.mozL10n, "get", function(id) {
+        switch(id) {
+          case "share_email_subject3": return "subject";
+          case "share_email_body3":    return "body";
+        }
+      });
+      composeEmail = sandbox.spy();
+      navigator.mozLoop = {
+        getLoopCharPref: sandbox.spy(),
+        composeEmail: composeEmail
+      };
+    });
+
+    it("should compose a call url email", function() {
+      sharedUtils.composeCallUrlEmail("http://invalid", "fake@invalid.tld");
+
+      sinon.assert.calledOnce(composeEmail);
+      sinon.assert.calledWith(composeEmail,
+                              "subject", "body", "fake@invalid.tld");
+    });
+  });
 });