Bug 1072323: Hook up the contact menus to be able to start outgoing calls. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Tue, 07 Oct 2014 17:10:56 +0200
changeset 209204 9a5c24c5e36acc4a114eea182da3373682b70d8e
parent 209203 9c56c35657b9eb4f115c4423b2acb435c5ce0d40
child 209205 fda842d4d3729a1d40eb6fa02c4307fd321c938a
push id27609
push userkwierso@gmail.com
push dateWed, 08 Oct 2014 01:15:41 +0000
treeherdermozilla-central@4b203cdd7d7d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1072323
milestone35.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 1072323: Hook up the contact menus to be able to start outgoing calls. r=mikedeboer
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/content/js/contacts.js
browser/components/loop/content/js/contacts.jsx
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/shared/js/actions.js
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/shared/conversationStore_test.js
browser/components/loop/test/shared/dispatcher_test.js
browser/components/loop/test/xpcshell/test_loopservice_directcall.js
browser/components/loop/test/xpcshell/xpcshell.ini
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -587,21 +587,36 @@ function injectLoopAPI(targetWindow) {
      */
     generateUUID: {
       enumerable: true,
       writable: true,
       value: function() {
         return MozLoopService.generateUUID();
       }
     },
+
+    /**
+     * Starts a direct call to the contact addresses.
+     *
+     * @param {Object} contact The contact to call
+     * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
+     * @return true if the call is opened, false if it is not opened (i.e. busy)
+     */
+    startDirectCall: {
+      enumerable: true,
+      writable: true,
+      value: function(contact, callType) {
+        MozLoopService.startDirectCall(contact, callType);
+      }
+    },
   };
 
   function onStatusChanged(aSubject, aTopic, aData) {
     let event = new targetWindow.CustomEvent("LoopStatusChanged");
-    targetWindow.dispatchEvent(event)
+    targetWindow.dispatchEvent(event);
   };
 
   function onDOMWindowDestroyed(aSubject, aTopic, aData) {
     if (targetWindow && aSubject != targetWindow)
       return;
     Services.obs.removeObserver(onDOMWindowDestroyed, "dom-window-destroyed");
     Services.obs.removeObserver(onStatusChanged, "loop-status-changed");
   };
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -714,35 +714,68 @@ let MozLoopServiceInternal = {
    */
 
   _processCalls: function(response, sessionType) {
     try {
       let respData = JSON.parse(response.body);
       if (respData.calls && Array.isArray(respData.calls)) {
         respData.calls.forEach((callData) => {
           if (!this.callsData.inUse) {
-            this.callsData.inUse = true;
             callData.sessionType = sessionType;
-            this.callsData.data = callData;
-            this.openChatWindow(
-              null,
-              this.localizedStrings["incoming_call_title2"].textContent,
-              "about:loopconversation#incoming/" + callData.callId);
+            this._startCall(callData, "incoming");
           } else {
             this._returnBusy(callData);
           }
         });
       } else {
         log.warn("Error: missing calls[] in response");
       }
     } catch (err) {
       log.warn("Error parsing calls info", err);
     }
   },
 
+  /**
+   * Starts a call, saves the call data, and opens a chat window.
+   *
+   * @param {Object} callData The data associated with the call including an id.
+   * @param {Boolean} conversationType Whether or not the call is "incoming"
+   *                                   or "outgoing"
+   */
+  _startCall: function(callData, conversationType) {
+    this.callsData.inUse = true;
+    this.callsData.data = callData;
+    this.openChatWindow(
+      null,
+      // No title, let the page set that, to avoid flickering.
+      "",
+      "about:loopconversation#" + conversationType + "/" + callData.callId);
+  },
+
+  /**
+   * Starts a direct call to the contact addresses.
+   *
+   * @param {Object} contact The contact to call
+   * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
+   * @return true if the call is opened, false if it is not opened (i.e. busy)
+   */
+  startDirectCall: function(contact, callType) {
+    if (this.callsData.inUse)
+      return false;
+
+    var callData = {
+      contact: contact,
+      callType: callType,
+      callId: Math.floor((Math.random() * 10))
+    };
+
+    this._startCall(callData, "outgoing");
+    return true;
+  },
+
    /**
    * Open call progress websocket and terminate with a reason of busy
    * the server.
    *
    * @param {callData} Must contain the progressURL, callId and websocketToken
    *                   returned by the LoopService.
    */
   _returnBusy: function(callData) {
@@ -1500,9 +1533,20 @@ this.MozLoopService = {
    *        or is rejected with an error.  If the server response can be parsed
    *        as JSON and contains an 'error' property, the promise will be
    *        rejected with this JSON-parsed response.
    */
   hawkRequest: function(sessionType, path, method, payloadObj) {
     return MozLoopServiceInternal.hawkRequest(sessionType, path, method, payloadObj).catch(
       error => {MozLoopServiceInternal._hawkRequestError(error);});
   },
+
+    /**
+     * Starts a direct call to the contact addresses.
+     *
+     * @param {Object} contact The contact to call
+     * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
+     * @return true if the call is opened, false if it is not opened (i.e. busy)
+     */
+  startDirectCall: function(contact, callType) {
+    MozLoopServiceInternal.startDirectCall(contact, callType);
+  },
 };
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -8,16 +8,17 @@
 /*global loop:true, React */
 
 var loop = loop || {};
 loop.contacts = (function(_, mozL10n) {
   "use strict";
 
   const Button = loop.shared.views.Button;
   const ButtonGroup = loop.shared.views.ButtonGroup;
+  const CALL_TYPES = loop.shared.utils.CALL_TYPES;
 
   // Number of contacts to add to the list at the same time.
   const CONTACTS_CHUNK_SIZE = 100;
 
   // At least this number of contacts should be present for the filter to appear.
   const MIN_CONTACTS_FOR_FILTERING = 7;
 
   let getContactNames = function(contact) {
@@ -80,24 +81,22 @@ loop.contacts = (function(_, mozL10n) {
 
       let blockAction = this.props.blocked ? "unblock" : "block";
       let blockLabel = this.props.blocked ? "unblock_contact_menu_button"
                                           : "block_contact_menu_button";
 
       return (
         React.DOM.ul({className: cx({ "dropdown-menu": true,
                             "dropdown-menu-up": this.state.openDirUp })}, 
-          React.DOM.li({className: cx({ "dropdown-menu-item": true,
-                              "disabled": true }), 
+          React.DOM.li({className: cx({ "dropdown-menu-item": true }), 
               onClick: this.onItemClick, 'data-action': "video-call"}, 
             React.DOM.i({className: "icon icon-video-call"}), 
             mozL10n.get("video_call_menu_button")
           ), 
-          React.DOM.li({className: cx({ "dropdown-menu-item": true,
-                              "disabled": true }), 
+          React.DOM.li({className: cx({ "dropdown-menu-item": true }), 
               onClick: this.onItemClick, 'data-action': "audio-call"}, 
             React.DOM.i({className: "icon icon-audio-call"}), 
             mozL10n.get("audio_call_menu_button")
           ), 
           React.DOM.li({className: cx({ "dropdown-menu-item": true,
                               "disabled": !this.props.canEdit }), 
               onClick: this.onItemClick, 'data-action': "edit"}, 
             React.DOM.i({className: "icon icon-edit"}), 
@@ -155,17 +154,18 @@ loop.contacts = (function(_, mozL10n) {
     },
 
     componentShouldUpdate: function(nextProps, nextState) {
       let currContact = this.props.contact;
       let nextContact = nextProps.contact;
       return (
         currContact.name[0] !== nextContact.name[0] ||
         currContact.blocked !== nextContact.blocked ||
-        getPreferredEmail(currContact).value !== getPreferredEmail(nextContact).value
+        getPreferredEmail(currContact).value !==
+          getPreferredEmail(nextContact).value
       );
     },
 
     handleAction: function(actionName) {
       if (this.props.handleContactAction) {
         this.props.handleContactAction(this.props.contact, actionName);
       }
     },
@@ -311,16 +311,22 @@ loop.contacts = (function(_, mozL10n) {
         case "unblock":
           // Invoke the API named like the action.
           navigator.mozLoop.contacts[actionName](contact._guid, err => {
             if (err) {
               throw err;
             }
           });
           break;
+        case "video-call":
+          navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_VIDEO);
+          break;
+        case "audio-call":
+          navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY);
+          break;
         default:
           console.error("Unrecognized action: " + actionName);
           break;
       }
     },
 
     sortContacts: function(contact1, contact2) {
       let comp = contact1.name[0].localeCompare(contact2.name[0]);
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -8,16 +8,17 @@
 /*global loop:true, React */
 
 var loop = loop || {};
 loop.contacts = (function(_, mozL10n) {
   "use strict";
 
   const Button = loop.shared.views.Button;
   const ButtonGroup = loop.shared.views.ButtonGroup;
+  const CALL_TYPES = loop.shared.utils.CALL_TYPES;
 
   // Number of contacts to add to the list at the same time.
   const CONTACTS_CHUNK_SIZE = 100;
 
   // At least this number of contacts should be present for the filter to appear.
   const MIN_CONTACTS_FOR_FILTERING = 7;
 
   let getContactNames = function(contact) {
@@ -80,24 +81,22 @@ loop.contacts = (function(_, mozL10n) {
 
       let blockAction = this.props.blocked ? "unblock" : "block";
       let blockLabel = this.props.blocked ? "unblock_contact_menu_button"
                                           : "block_contact_menu_button";
 
       return (
         <ul className={cx({ "dropdown-menu": true,
                             "dropdown-menu-up": this.state.openDirUp })}>
-          <li className={cx({ "dropdown-menu-item": true,
-                              "disabled": true })}
+          <li className={cx({ "dropdown-menu-item": true })}
               onClick={this.onItemClick} data-action="video-call">
             <i className="icon icon-video-call" />
             {mozL10n.get("video_call_menu_button")}
           </li>
-          <li className={cx({ "dropdown-menu-item": true,
-                              "disabled": true })}
+          <li className={cx({ "dropdown-menu-item": true })}
               onClick={this.onItemClick} data-action="audio-call">
             <i className="icon icon-audio-call" />
             {mozL10n.get("audio_call_menu_button")}
           </li>
           <li className={cx({ "dropdown-menu-item": true,
                               "disabled": !this.props.canEdit })}
               onClick={this.onItemClick} data-action="edit">
             <i className="icon icon-edit" />
@@ -155,17 +154,18 @@ loop.contacts = (function(_, mozL10n) {
     },
 
     componentShouldUpdate: function(nextProps, nextState) {
       let currContact = this.props.contact;
       let nextContact = nextProps.contact;
       return (
         currContact.name[0] !== nextContact.name[0] ||
         currContact.blocked !== nextContact.blocked ||
-        getPreferredEmail(currContact).value !== getPreferredEmail(nextContact).value
+        getPreferredEmail(currContact).value !==
+          getPreferredEmail(nextContact).value
       );
     },
 
     handleAction: function(actionName) {
       if (this.props.handleContactAction) {
         this.props.handleContactAction(this.props.contact, actionName);
       }
     },
@@ -311,16 +311,22 @@ loop.contacts = (function(_, mozL10n) {
         case "unblock":
           // Invoke the API named like the action.
           navigator.mozLoop.contacts[actionName](contact._guid, err => {
             if (err) {
               throw err;
             }
           });
           break;
+        case "video-call":
+          navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_VIDEO);
+          break;
+        case "audio-call":
+          navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY);
+          break;
         default:
           console.error("Unrecognized action: " + actionName);
           break;
       }
     },
 
     sortContacts: function(contact1, contact2) {
       let comp = contact1.name[0].localeCompare(contact2.name[0]);
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -546,54 +546,61 @@ loop.conversation = (function(mozL10n) {
     });
 
     var conversationStore = new loop.store.ConversationStore({}, {
       client: client,
       dispatcher: dispatcher,
       sdkDriver: sdkDriver
     });
 
-    // XXX For now key this on the pref, but this should really be
-    // set by the information from the mozLoop API when we can get it (bug 1072323).
-    var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail");
-
     // XXX Old class creation for the incoming conversation view, whilst
     // we transition across (bug 1072323).
     var conversation = new sharedModels.ConversationModel(
       {},                // Model attributes
       {sdk: window.OT}   // Model dependencies
     );
 
     // Obtain the callId and pass it through
     var helper = new loop.shared.utils.Helper();
     var locationHash = helper.locationHash();
     var callId;
-    if (locationHash) {
-      callId = locationHash.match(/\#incoming\/(.*)/)[1]
-      conversation.set("callId", callId);
+    var outgoing;
+
+    var hash = locationHash.match(/\#incoming\/(.*)/);
+    if (hash) {
+      callId = hash[1];
+      outgoing = false;
+    } else {
+      hash = locationHash.match(/\#outgoing\/(.*)/);
+      if (hash) {
+        callId = hash[1];
+        outgoing = true;
+      }
     }
 
+    conversation.set({callId: callId});
+
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
-      navigator.mozLoop.releaseCallData(conversation.get("callId"));
+      navigator.mozLoop.releaseCallData(callId);
     });
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
 
     React.renderComponent(ConversationControllerView({
       store: conversationStore, 
       client: client, 
       conversation: conversation, 
       dispatcher: dispatcher, 
       sdk: window.OT}
     ), document.querySelector('#main'));
 
     dispatcher.dispatch(new loop.shared.actions.GatherCallData({
       callId: callId,
-      calleeId: outgoingEmail
+      outgoing: outgoing
     }));
   }
 
   return {
     ConversationControllerView: ConversationControllerView,
     IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     init: init
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -546,54 +546,61 @@ loop.conversation = (function(mozL10n) {
     });
 
     var conversationStore = new loop.store.ConversationStore({}, {
       client: client,
       dispatcher: dispatcher,
       sdkDriver: sdkDriver
     });
 
-    // XXX For now key this on the pref, but this should really be
-    // set by the information from the mozLoop API when we can get it (bug 1072323).
-    var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail");
-
     // XXX Old class creation for the incoming conversation view, whilst
     // we transition across (bug 1072323).
     var conversation = new sharedModels.ConversationModel(
       {},                // Model attributes
       {sdk: window.OT}   // Model dependencies
     );
 
     // Obtain the callId and pass it through
     var helper = new loop.shared.utils.Helper();
     var locationHash = helper.locationHash();
     var callId;
-    if (locationHash) {
-      callId = locationHash.match(/\#incoming\/(.*)/)[1]
-      conversation.set("callId", callId);
+    var outgoing;
+
+    var hash = locationHash.match(/\#incoming\/(.*)/);
+    if (hash) {
+      callId = hash[1];
+      outgoing = false;
+    } else {
+      hash = locationHash.match(/\#outgoing\/(.*)/);
+      if (hash) {
+        callId = hash[1];
+        outgoing = true;
+      }
     }
 
+    conversation.set({callId: callId});
+
     window.addEventListener("unload", function(event) {
       // Handle direct close of dialog box via [x] control.
-      navigator.mozLoop.releaseCallData(conversation.get("callId"));
+      navigator.mozLoop.releaseCallData(callId);
     });
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
 
     React.renderComponent(<ConversationControllerView
       store={conversationStore}
       client={client}
       conversation={conversation}
       dispatcher={dispatcher}
       sdk={window.OT}
     />, document.querySelector('#main'));
 
     dispatcher.dispatch(new loop.shared.actions.GatherCallData({
       callId: callId,
-      calleeId: outgoingEmail
+      outgoing: outgoing
     }));
   }
 
   return {
     ConversationControllerView: ConversationControllerView,
     IncomingConversationView: IncomingConversationView,
     IncomingCallView: IncomingCallView,
     init: init
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -18,40 +18,60 @@ loop.conversationViews = (function(mozL1
    * 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: {
-      calleeId: React.PropTypes.string,
+      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() {
-      document.title = this.props.calleeId;
+      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;
+      }
+
+      document.title = contactName;
 
       return (
         React.DOM.div({className: "call-window"}, 
-          React.DOM.h2(null, this.props.calleeId), 
+          React.DOM.h2(null, contactName), 
           React.DOM.div(null, this.props.children)
         )
       );
     }
   });
 
   /**
    * View for pending conversations. Displays a cancel button and appropriate
    * pending/ringing strings.
    */
   var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       callState: React.PropTypes.string,
-      calleeId: React.PropTypes.string,
+      contact: React.PropTypes.object,
       enableCancelButton: React.PropTypes.bool
     },
 
     getDefaultProps: function() {
       return {
         enableCancelButton: false
       };
     },
@@ -71,17 +91,17 @@ loop.conversationViews = (function(mozL1
 
       var btnCancelStyles = cx({
         "btn": true,
         "btn-cancel": true,
         "disabled": !this.props.enableCancelButton
       });
 
       return (
-        ConversationDetailView({calleeId: this.props.calleeId}, 
+        ConversationDetailView({contact: this.props.contact}, 
 
           React.DOM.p({className: "btn-label"}, pendingStateString), 
 
           React.DOM.div({className: "btn-group call-action-group"}, 
             React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
               React.DOM.button({className: btnCancelStyles, 
                       onClick: this.cancelCall}, 
                 mozL10n.get("initiate_call_cancel_button")
@@ -335,29 +355,29 @@ loop.conversationViews = (function(mozL1
         case CALL_STATES.TERMINATED: {
           return (CallFailedView({
             dispatcher: this.props.dispatcher}
           ));
         }
         case CALL_STATES.ONGOING: {
           return (OngoingConversationView({
             dispatcher: this.props.dispatcher, 
-            video: {enabled: this.state.videoMuted}, 
-            audio: {enabled: this.state.audioMuted}}
+            video: {enabled: !this.state.videoMuted}, 
+            audio: {enabled: !this.state.audioMuted}}
             )
           );
         }
         case CALL_STATES.FINISHED: {
           return this._renderFeedbackView();
         }
         default: {
           return (PendingConversationView({
             dispatcher: this.props.dispatcher, 
             callState: this.state.callState, 
-            calleeId: this.state.calleeId, 
+            contact: this.state.contact, 
             enableCancelButton: this._isCancellable()}
           ))
         }
       }
     },
   });
 
   return {
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -18,40 +18,60 @@ loop.conversationViews = (function(mozL1
    * 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: {
-      calleeId: React.PropTypes.string,
+      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() {
-      document.title = this.props.calleeId;
+      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;
+      }
+
+      document.title = contactName;
 
       return (
         <div className="call-window">
-          <h2>{this.props.calleeId}</h2>
+          <h2>{contactName}</h2>
           <div>{this.props.children}</div>
         </div>
       );
     }
   });
 
   /**
    * View for pending conversations. Displays a cancel button and appropriate
    * pending/ringing strings.
    */
   var PendingConversationView = React.createClass({
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       callState: React.PropTypes.string,
-      calleeId: React.PropTypes.string,
+      contact: React.PropTypes.object,
       enableCancelButton: React.PropTypes.bool
     },
 
     getDefaultProps: function() {
       return {
         enableCancelButton: false
       };
     },
@@ -71,17 +91,17 @@ loop.conversationViews = (function(mozL1
 
       var btnCancelStyles = cx({
         "btn": true,
         "btn-cancel": true,
         "disabled": !this.props.enableCancelButton
       });
 
       return (
-        <ConversationDetailView calleeId={this.props.calleeId}>
+        <ConversationDetailView contact={this.props.contact}>
 
           <p className="btn-label">{pendingStateString}</p>
 
           <div className="btn-group call-action-group">
             <div className="fx-embedded-call-button-spacer"></div>
               <button className={btnCancelStyles}
                       onClick={this.cancelCall}>
                 {mozL10n.get("initiate_call_cancel_button")}
@@ -335,29 +355,29 @@ loop.conversationViews = (function(mozL1
         case CALL_STATES.TERMINATED: {
           return (<CallFailedView
             dispatcher={this.props.dispatcher}
           />);
         }
         case CALL_STATES.ONGOING: {
           return (<OngoingConversationView
             dispatcher={this.props.dispatcher}
-            video={{enabled: this.state.videoMuted}}
-            audio={{enabled: this.state.audioMuted}}
+            video={{enabled: !this.state.videoMuted}}
+            audio={{enabled: !this.state.audioMuted}}
             />
           );
         }
         case CALL_STATES.FINISHED: {
           return this._renderFeedbackView();
         }
         default: {
           return (<PendingConversationView
             dispatcher={this.props.dispatcher}
             callState={this.state.callState}
-            calleeId={this.state.calleeId}
+            contact={this.state.contact}
             enableCancelButton={this._isCancellable()}
           />)
         }
       }
     },
   });
 
   return {
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -29,21 +29,19 @@ loop.shared.actions = (function() {
     return Action.bind(null, name, schema);
   };
 
   return {
     /**
      * Used to trigger gathering of initial call data.
      */
     GatherCallData: Action.define("gatherCallData", {
-      // XXX This may change when bug 1072323 is implemented.
-      // Optional: Specify the calleeId for an outgoing call
-      calleeId: [String, null],
       // Specify the callId for an incoming call.
-      callId: [String, null]
+      callId: [String, null],
+      outgoing: Boolean
     }),
 
     /**
      * Used to cancel call setup.
      */
     CancelCall: Action.define("cancelCall", {
     }),
 
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -58,18 +58,18 @@ loop.store = (function() {
       // The current state of the call
       callState: CALL_STATES.INIT,
       // The reason if a call was terminated
       callStateReason: undefined,
       // The error information, if there was a failure
       error: undefined,
       // True if the call is outgoing, false if not, undefined if unknown
       outgoing: undefined,
-      // The id of the person being called for outgoing calls
-      calleeId: undefined,
+      // The contact being called for outgoing calls
+      contact: undefined,
       // The call type for the call.
       // XXX Don't hard-code, this comes from the data in bug 1072323
       callType: CALL_TYPES.AUDIO_VIDEO,
 
       // Call Connection information
       // The call id from the loop-server
       callId: undefined,
       // The connection progress url to connect the websocket
@@ -78,19 +78,19 @@ loop.store = (function() {
       websocketToken: undefined,
       // SDK API key
       apiKey: undefined,
       // SDK session ID
       sessionId: undefined,
       // SDK session token
       sessionToken: undefined,
       // If the audio is muted
-      audioMuted: true,
+      audioMuted: false,
       // If the video is muted
-      videoMuted: true
+      videoMuted: false
     },
 
     /**
      * Constructor
      *
      * Options:
      * - {loop.Dispatcher} dispatcher The dispatcher for dispatching actions and
      *                                registering to consume actions.
@@ -187,24 +187,39 @@ loop.store = (function() {
 
     /**
      * Handles the gather call data action, setting the state
      * and starting to get the appropriate data for the type of call.
      *
      * @param {sharedActions.GatherCallData} actionData The action data.
      */
     gatherCallData: function(actionData) {
+      if (!actionData.outgoing) {
+        // XXX Other types aren't supported yet, but set the state for the
+        // view selection.
+        this.set({outgoing: false});
+        return;
+      }
+
+      var callData = navigator.mozLoop.getCallData(actionData.callId);
+      if (!callData) {
+        console.error("Failed to get the call data");
+        this.set({callState: CALL_STATES.TERMINATED});
+        return;
+      }
+
       this.set({
-        calleeId: actionData.calleeId,
-        outgoing: !!actionData.calleeId,
+        contact: callData.contact,
+        outgoing: actionData.outgoing,
         callId: actionData.callId,
+        callType: callData.callType,
         callState: CALL_STATES.GATHER
       });
 
-      this.videoMuted = this.get("callType") !== CALL_TYPES.AUDIO_VIDEO;
+      this.set({videoMuted: this.get("callType") === CALL_TYPES.AUDIO_ONLY});
 
       if (this.get("outgoing")) {
         this._setupOutgoingCall();
       } // XXX Else, other types aren't supported yet.
     },
 
     /**
      * Handles the connect call action, this saves the appropriate
@@ -280,26 +295,31 @@ loop.store = (function() {
 
     /**
      * Records the mute state for the stream.
      *
      * @param {sharedActions.setMute} actionData The mute state for the stream type.
      */
     setMute: function(actionData) {
       var muteType = actionData.type + "Muted";
-      this.set(muteType, actionData.enabled);
+      this.set(muteType, !actionData.enabled);
     },
 
     /**
      * Obtains the outgoing call data from the server and handles the
      * result.
      */
     _setupOutgoingCall: function() {
-      // XXX For now, we only have one calleeId, so just wrap that in an array.
-      this.client.setupOutgoingCall([this.get("calleeId")],
+      var contactAddresses = [];
+
+      this.get("contact").email.forEach(function(address) {
+        contactAddresses.push(address.value);
+      });
+
+      this.client.setupOutgoingCall(contactAddresses,
         this.get("callType"),
         function(err, result) {
           if (err) {
             console.error("Failed to get outgoing call data", err);
             this.dispatcher.dispatch(
               new sharedActions.ConnectionFailure({reason: "setup"}));
             return;
           }
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -1,123 +1,143 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 var expect = chai.expect;
 
 describe("loop.conversationViews", function () {
-  var sandbox, oldTitle, view, dispatcher;
+  var sandbox, oldTitle, view, dispatcher, contact;
 
   var CALL_STATES = loop.store.CALL_STATES;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
     oldTitle = document.title;
     sandbox.stub(document.mozL10n, "get", function(x) {
       return x;
     });
 
     dispatcher = new loop.Dispatcher();
     sandbox.stub(dispatcher, "dispatch");
+
+    contact = {
+      name: [ "mrsmith" ],
+      email: [{
+        type: "home",
+        value: "fakeEmail",
+        pref: true
+      }]
+    };
   });
 
   afterEach(function() {
     document.title = oldTitle;
     view = undefined;
     sandbox.restore();
   });
 
   describe("ConversationDetailView", function() {
     function mountTestComponent(props) {
       return TestUtils.renderIntoDocument(
         loop.conversationViews.ConversationDetailView(props));
     }
 
     it("should set the document title to the calledId", function() {
-      mountTestComponent({calleeId: "mrsmith"});
+      mountTestComponent({contact: contact});
 
       expect(document.title).eql("mrsmith");
     });
 
     it("should set display the calledId", function() {
-      view = mountTestComponent({calleeId: "mrsmith"});
+      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});
+
+        expect(TestUtils.findRenderedDOMComponentWithTag(
+          view, "h2").props.children).eql("fakeEmail");
+      }
+    );
   });
 
   describe("PendingConversationView", function() {
     function mountTestComponent(props) {
       return TestUtils.renderIntoDocument(
         loop.conversationViews.PendingConversationView(props));
     }
 
     it("should set display connecting string when the state is not alerting",
       function() {
         view = mountTestComponent({
           callState: CALL_STATES.CONNECTING,
-          calleeId: "mrsmith",
+          contact: contact,
           dispatcher: dispatcher
         });
 
         var label = TestUtils.findRenderedDOMComponentWithClass(
           view, "btn-label").props.children;
 
         expect(label).to.have.string("connecting");
     });
 
     it("should set display ringing string when the state is alerting",
       function() {
         view = mountTestComponent({
           callState: CALL_STATES.ALERTING,
-          calleeId: "mrsmith",
+          contact: contact,
           dispatcher: dispatcher
         });
 
         var label = TestUtils.findRenderedDOMComponentWithClass(
           view, "btn-label").props.children;
 
         expect(label).to.have.string("ringing");
     });
 
     it("should disable the cancel button if enableCancelButton is false",
       function() {
         view = mountTestComponent({
           callState: CALL_STATES.CONNECTING,
-          calleeId: "mrsmith",
+          contact: contact,
           dispatcher: dispatcher,
           enableCancelButton: false
         });
 
         var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
 
         expect(cancelBtn.classList.contains("disabled")).eql(true);
       });
 
     it("should enable the cancel button if enableCancelButton is false",
       function() {
         view = mountTestComponent({
           callState: CALL_STATES.CONNECTING,
-          calleeId: "mrsmith",
+          contact: contact,
           dispatcher: dispatcher,
           enableCancelButton: true
         });
 
         var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
 
         expect(cancelBtn.classList.contains("disabled")).eql(false);
       });
 
     it("should dispatch a cancelCall action when the cancel button is pressed",
       function() {
         view = mountTestComponent({
           callState: CALL_STATES.CONNECTING,
-          calleeId: "mrsmith",
+          contact: contact,
           dispatcher: dispatcher
         });
 
         var cancelBtn = view.getDOMNode().querySelector('.btn-cancel');
 
         React.addons.TestUtils.Simulate.click(cancelBtn);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
@@ -288,17 +308,20 @@ describe("loop.conversationViews", funct
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           loop.conversationViews.CallFailedView);
     });
 
     it("should render the PendingConversationView when the call state is 'init'",
       function() {
-        store.set({callState: CALL_STATES.INIT});
+        store.set({
+          callState: CALL_STATES.INIT,
+          contact: contact
+        });
 
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           loop.conversationViews.PendingConversationView);
     });
 
     it("should render the OngoingConversationView when the call state is 'ongoing'",
@@ -318,17 +341,20 @@ describe("loop.conversationViews", funct
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           loop.shared.views.FeedbackView);
     });
 
     it("should update the rendered views when the state is changed.",
       function() {
-        store.set({callState: CALL_STATES.INIT});
+        store.set({
+          callState: CALL_STATES.INIT,
+          contact: contact
+        });
 
         view = mountTestComponent();
 
         TestUtils.findRenderedComponentWithType(view,
           loop.conversationViews.PendingConversationView);
 
         store.set({callState: CALL_STATES.TERMINATED});
 
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -110,20 +110,34 @@ describe("loop.conversation", function()
     });
 
     it("should trigger a gatherCallData action", function() {
       loop.conversation.init();
 
       sinon.assert.calledOnce(loop.Dispatcher.prototype.dispatch);
       sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch,
         new loop.shared.actions.GatherCallData({
-          calleeId: null,
-          callId: "42"
+          callId: "42",
+          outgoing: false
         }));
     });
+
+    it("should trigger an outgoing gatherCallData action for outgoing calls",
+      function() {
+        loop.shared.utils.Helper.prototype.locationHash.returns("#outgoing/24");
+
+        loop.conversation.init();
+
+        sinon.assert.calledOnce(loop.Dispatcher.prototype.dispatch);
+        sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch,
+          new loop.shared.actions.GatherCallData({
+            callId: "24",
+            outgoing: true
+          }));
+      });
   });
 
   describe("ConversationControllerView", function() {
     var store, conversation, client, ccView, oldTitle, dispatcher;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversation.ConversationControllerView({
@@ -136,17 +150,26 @@ describe("loop.conversation", function()
 
     beforeEach(function() {
       oldTitle = document.title;
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
         sdk: {}
       });
       dispatcher = new loop.Dispatcher();
-      store = new loop.store.ConversationStore({}, {
+      store = new loop.store.ConversationStore({
+        contact: {
+          name: [ "Mr Smith" ],
+          email: [{
+            type: "home",
+            value: "fakeEmail",
+            pref: true
+          }]
+        }
+      }, {
         client: client,
         dispatcher: dispatcher,
         sdkDriver: {}
       });
     });
 
     afterEach(function() {
       ccView = undefined;
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -6,31 +6,41 @@ var expect = chai.expect;
 describe("loop.ConversationStore", function () {
   "use strict";
 
   var CALL_STATES = loop.store.CALL_STATES;
   var WS_STATES = loop.store.WS_STATES;
   var sharedActions = loop.shared.actions;
   var sharedUtils = loop.shared.utils;
   var sandbox, dispatcher, client, store, fakeSessionData, sdkDriver;
+  var contact;
   var connectPromise, resolveConnectPromise, rejectConnectPromise;
   var wsCancelSpy, wsCloseSpy, wsMediaUpSpy, fakeWebsocket;
 
   function checkFailures(done, f) {
     try {
       f();
       done();
     } catch (err) {
       done(err);
     }
   }
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
+    contact = {
+      name: [ "Mr Smith" ],
+      email: [{
+        type: "home",
+        value: "fakeEmail",
+        pref: true
+      }]
+    };
+
     dispatcher = new loop.Dispatcher();
     client = {
       setupOutgoingCall: sinon.stub()
     };
     sdkDriver = {
       connectSession: sinon.stub(),
       disconnectSession: sinon.stub()
     };
@@ -194,57 +204,80 @@ describe("loop.ConversationStore", funct
         });
       });
     });
   });
 
   describe("#gatherCallData", function() {
     beforeEach(function() {
       store.set({callState: CALL_STATES.INIT});
+
+      navigator.mozLoop = {
+        getCallData: function() {
+          return {
+            contact: contact,
+            callType: sharedUtils.CALL_TYPES.AUDIO_VIDEO
+          };
+        }
+      };
+    });
+
+    afterEach(function() {
+      delete navigator.mozLoop;
     });
 
     it("should set the state to 'gather'", function() {
       dispatcher.dispatch(
         new sharedActions.GatherCallData({
-          calleeId: "",
-          callId: "76543218"
+          callId: "76543218",
+          outgoing: true
         }));
 
       expect(store.get("callState")).eql(CALL_STATES.GATHER);
     });
 
     it("should save the basic call information", function() {
       dispatcher.dispatch(
         new sharedActions.GatherCallData({
-          calleeId: "fake",
-          callId: "123456"
+          callId: "123456",
+          outgoing: true
         }));
 
-      expect(store.get("calleeId")).eql("fake");
       expect(store.get("callId")).eql("123456");
       expect(store.get("outgoing")).eql(true);
     });
 
+    it("should save the basic information from the mozLoop api", function() {
+      dispatcher.dispatch(
+        new sharedActions.GatherCallData({
+          callId: "123456",
+          outgoing: true
+        }));
+
+      expect(store.get("contact")).eql(contact);
+      expect(store.get("callType")).eql(sharedUtils.CALL_TYPES.AUDIO_VIDEO);
+    });
+
     describe("outgoing calls", function() {
       var outgoingCallData;
 
       beforeEach(function() {
         outgoingCallData = {
-          calleeId: "fake",
-          callId: "135246"
+          callId: "123456",
+          outgoing: true
         };
       });
 
       it("should request the outgoing call data", function() {
         dispatcher.dispatch(
           new sharedActions.GatherCallData(outgoingCallData));
 
         sinon.assert.calledOnce(client.setupOutgoingCall);
         sinon.assert.calledWith(client.setupOutgoingCall,
-          ["fake"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
+          ["fakeEmail"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
       });
 
       describe("server response handling", function() {
         beforeEach(function() {
           sandbox.stub(dispatcher, "dispatch");
         });
 
         it("should dispatch a connect call action on success", function() {
@@ -483,24 +516,24 @@ describe("loop.ConversationStore", funct
       expect(store.get("callState")).eql(CALL_STATES.GATHER);
     });
 
     it("should request the outgoing call data", function() {
       store.set({
         callState: CALL_STATES.TERMINATED,
         outgoing: true,
         callType: sharedUtils.CALL_TYPES.AUDIO_VIDEO,
-        calleeId: "fake"
+        contact: contact
       });
 
       dispatcher.dispatch(new sharedActions.RetryCall());
 
       sinon.assert.calledOnce(client.setupOutgoingCall);
       sinon.assert.calledWith(client.setupOutgoingCall,
-        ["fake"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
+        ["fakeEmail"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
     });
   });
 
   describe("#mediaConnected", function() {
     it("should send mediaUp via the websocket", function() {
       store._websocket = fakeWebsocket;
 
       dispatcher.dispatch(new sharedActions.MediaConnected());
@@ -513,28 +546,28 @@ describe("loop.ConversationStore", funct
     it("should save the mute state for the audio stream", function() {
       store.set({"audioMuted": false});
 
       dispatcher.dispatch(new sharedActions.SetMute({
         type: "audio",
         enabled: true
       }));
 
-      expect(store.get("audioMuted")).eql(true);
+      expect(store.get("audioMuted")).eql(false);
     });
 
     it("should save the mute state for the video stream", function() {
       store.set({"videoMuted": true});
 
       dispatcher.dispatch(new sharedActions.SetMute({
         type: "video",
         enabled: false
       }));
 
-      expect(store.get("videoMuted")).eql(false);
+      expect(store.get("videoMuted")).eql(true);
     });
   });
 
   describe("Events", function() {
     describe("Websocket progress", function() {
       beforeEach(function() {
         dispatcher.dispatch(
           new sharedActions.ConnectCall({sessionData: fakeSessionData}));
--- a/browser/components/loop/test/shared/dispatcher_test.js
+++ b/browser/components/loop/test/shared/dispatcher_test.js
@@ -41,17 +41,17 @@ describe("loop.Dispatcher", function () 
 
   describe("#dispatch", function() {
     var gatherStore1, gatherStore2, cancelStore1, connectStore1;
     var gatherAction, cancelAction, connectAction, resolveCancelStore1;
 
     beforeEach(function() {
       gatherAction = new sharedActions.GatherCallData({
         callId: "42",
-        calleeId: null
+        outgoing: false
       });
 
       cancelAction = new sharedActions.CancelCall();
       connectAction = new sharedActions.ConnectCall({
         sessionData: {}
       });
 
       gatherStore1 = {
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/xpcshell/test_loopservice_directcall.js
@@ -0,0 +1,59 @@
+/* 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/. */
+
+XPCOMUtils.defineLazyModuleGetter(this, "Chat",
+                                  "resource:///modules/Chat.jsm");
+let openChatOrig = Chat.open;
+
+const contact = {
+  name: [ "Mr Smith" ],
+  email: [{
+    type: "home",
+    value: "fakeEmail",
+    pref: true
+  }]
+};
+
+add_task(function test_startDirectCall_opens_window() {
+  let openedUrl;
+  Chat.open = function(contentWindow, origin, title, url) {
+    openedUrl = url;
+  };
+
+  MozLoopService.startDirectCall(contact, "audio-video");
+
+  do_check_true(!!openedUrl, "should open a chat window");
+
+  // Stop the busy kicking in for following tests.
+  let callId = openedUrl.match(/about:loopconversation\#outgoing\/(.*)/)[1];
+  MozLoopService.releaseCallData(callId);
+});
+
+add_task(function test_startDirectCall_getCallData() {
+  let openedUrl;
+  Chat.open = function(contentWindow, origin, title, url) {
+    openedUrl = url;
+  };
+
+  MozLoopService.startDirectCall(contact, "audio-video");
+
+  let callId = openedUrl.match(/about:loopconversation\#outgoing\/(.*)/)[1];
+
+  let callData = MozLoopService.getCallData(callId);
+
+  do_check_eq(callData.callType, "audio-video", "should have the correct call type");
+  do_check_eq(callData.contact, contact, "should have the contact details");
+
+  // Stop the busy kicking in for following tests.
+  MozLoopService.releaseCallData(callId);
+});
+
+function run_test() {
+  do_register_cleanup(function() {
+    // Revert original Chat.open implementation
+    Chat.open = openChatOrig;
+  });
+
+  run_next_test();
+}
--- a/browser/components/loop/test/xpcshell/xpcshell.ini
+++ b/browser/components/loop/test/xpcshell/xpcshell.ini
@@ -1,20 +1,21 @@
 [DEFAULT]
 head = head.js
 tail =
 firefox-appdir = browser
 
 [test_loopapi_hawk_request.js]
 [test_looppush_initialize.js]
+[test_loopservice_directcall.js]
 [test_loopservice_dnd.js]
 [test_loopservice_expiry.js]
 [test_loopservice_hawk_errors.js]
 [test_loopservice_loop_prefs.js]
 [test_loopservice_initialize.js]
 [test_loopservice_locales.js]
 [test_loopservice_notification.js]
 [test_loopservice_registration.js]
 [test_loopservice_token_invalid.js]
 [test_loopservice_token_save.js]
 [test_loopservice_token_send.js]
 [test_loopservice_token_validation.js]
-[test_loopservice_busy.js]
\ No newline at end of file
+[test_loopservice_busy.js]