Bug 1079941: implement LoopContacts.search to allow searching for contacts by query and use that to find out if a contact who's trying to call you is blocked. r=abr
authorMike de Boer <mdeboer@mozilla.com>
Thu, 16 Oct 2014 16:35:10 +0200
changeset 210897 e8a01f5feb55744a9cb6c0d5b180f0cf71973524
parent 210896 ba0bb4f26680441eb0fcc5c29daa4721c35e329f
child 210898 3b8d0c8b28a5ba028e8425e56d3f9a18565709a4
push id27664
push userkwierso@gmail.com
push dateSat, 18 Oct 2014 02:38:02 +0000
treeherdermozilla-central@92c87e95915e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersabr
bugs1079941
milestone36.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 1079941: implement LoopContacts.search to allow searching for contacts by query and use that to find out if a contact who's trying to call you is blocked. r=abr
browser/components/loop/LoopContacts.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/content/js/contacts.js
browser/components/loop/content/js/contacts.jsx
browser/components/loop/test/mochitest/browser_LoopContacts.js
--- a/browser/components/loop/LoopContacts.jsm
+++ b/browser/components/loop/LoopContacts.jsm
@@ -785,26 +785,95 @@ let LoopContactsInternal = Object.freeze
       return;
     }
     this._importServices[options.service].startImport(options, callback,
                                                       LoopContacts, windowRef);
   },
 
   /**
    * Search through the data store for contacts that match a certain (sub-)string.
+   * NB: The current implementation is very simple, naive if you will; we fetch
+   *     _all_ the contacts via `getAll()` and iterate over all of them to find
+   *     the contacts matching the supplied query (brute-force search in
+   *     exponential time).
    *
-   * @param {String}   query    Needle to search for in our haystack of contacts
+   * @param {Object}   query    Needle to search for in our haystack of contacts
    * @param {Function} callback Function that will be invoked once the operation
    *                            finished. The first argument passed will be an
    *                            `Error` object or `null`. The second argument will
    *                            be an `Array` of contact objects, if successfull.
+   *
+   * Example:
+   *   LoopContacts.search({
+   *     q: "foo@bar.com",
+   *     field: "email" // 'email' is the default.
+   *   }, function(err, contacts) {
+   *     if (err) {
+   *       throw err;
+   *     }
+   *     console.dir(contacts);
+   *   });
    */
   search: function(query, callback) {
-    //TODO in bug 1037114.
-    callback(new Error("Not implemented yet!"));
+    if (!("q" in query) || !query.q) {
+      callback(new Error("Nothing to search for. 'q' is required."));
+      return;
+    }
+    if (!("field" in query)) {
+      query.field = "email";
+    }
+    let queryValue = query.q;
+    if (query.field == "tel") {
+      queryValue = queryValue.replace(/[\D]+/g, "");
+    }
+
+    const checkForMatch = function(fieldValue) {
+      if (typeof fieldValue == "string") {
+        if (query.field == "tel") {
+          return fieldValue.replace(/[\D]+/g, "").endsWith(queryValue);
+        }
+        return fieldValue == queryValue;
+      }
+      if (typeof fieldValue == "number" || typeof fieldValue == "boolean") {
+        return fieldValue == queryValue;
+      }
+      if ("value" in fieldValue) {
+        return checkForMatch(fieldValue.value);
+      }
+      return false;
+    };
+
+    let foundContacts = [];
+    this.getAll((err, contacts) => {
+      if (err) {
+        callback(err);
+        return;
+      }
+
+      for (let contact of contacts) {
+        let matchWith = contact[query.field];
+        if (!matchWith) {
+          continue;
+        }
+
+        // Many fields are defined as Arrays.
+        if (Array.isArray(matchWith)) {
+          for (let fieldValue of matchWith) {
+            if (checkForMatch(fieldValue)) {
+              foundContacts.push(contact);
+              break;
+            }
+          }
+        } else if (checkForMatch(matchWith)) {
+          foundContacts.push(contact);
+        }
+      }
+
+      callback(null, foundContacts);
+    });
   }
 });
 
 /**
  * Public Loop Contacts API.
  *
  * LoopContacts implements the EventEmitter interface by exposing three methods -
  * `on`, `once` and `off` - to subscribe to events.
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -18,16 +18,18 @@ const MAX_SOFT_START_TICKET_NUMBER = 167
 const LOOP_SESSION_TYPE = {
   GUEST: 1,
   FXA: 2,
 };
 
 // See LOG_LEVELS in Console.jsm. Common examples: "All", "Info", "Warn", & "Error".
 const PREF_LOG_LEVEL = "loop.debug.loglevel";
 
+const EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
+
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
 Cu.importGlobalProperties(["URL"]);
 
@@ -51,16 +53,19 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/FxAccountsProfileClient.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "HawkClient",
                                   "resource://services-common/hawkclient.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "deriveHawkCredentials",
                                   "resource://services-common/hawkrequest.js");
 
+XPCOMUtils.defineLazyModuleGetter(this, "LoopContacts",
+                                  "resource:///modules/loop/LoopContacts.jsm");
+
 XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
                                   "resource:///modules/loop/LoopStorage.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
                                   "resource:///modules/loop/MozLoopPushHandler.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
                                    "@mozilla.org/uuid-generator;1",
@@ -781,27 +786,56 @@ let MozLoopServiceInternal = {
       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"
+   * @param {String} 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);
+    const openChat = () => {
+      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);
+    };
+
+    if (conversationType == "incoming" && ("callerId" in callData) &&
+        EMAIL_OR_PHONE_RE.test(callData.callerId)) {
+      LoopContacts.search({
+        q: callData.callerId,
+        field: callData.callerId.contains("@") ? "email" : "tel"
+      }, (err, contacts) => {
+        if (err) {
+          // Database error, helas!
+          openChat();
+          return;
+        }
+
+        for (let contact of contacts) {
+          if (contact.blocked) {
+            // Blocked! Send a busy signal back to the caller.
+            this._returnBusy(callData);
+            return;
+          }
+        }
+
+        openChat();
+      })
+    } else {
+      openChat();
+    }
   },
 
   /**
    * 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)
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -81,22 +81,25 @@ 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 }), 
-              onClick: this.onItemClick, 'data-action': "video-call"}, 
+          React.DOM.li({className: cx({ "dropdown-menu-item": true,
+                              "disabled": this.props.blocked }), 
+              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 }), 
+          React.DOM.li({className: cx({ "dropdown-menu-item": true,
+                              "disabled": this.props.blocked }), 
               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"}), 
@@ -383,20 +386,24 @@ loop.contacts = (function(_, mozL10n) {
           // 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);
+          if (!contact.blocked) {
+            navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_VIDEO);
+          }
           break;
         case "audio-call":
-          navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY);
+          if (!contact.blocked) {
+            navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY);
+          }
           break;
         default:
           console.error("Unrecognized action: " + actionName);
           break;
       }
     },
 
     sortContacts: function(contact1, contact2) {
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -81,22 +81,25 @@ 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 })}
-              onClick={this.onItemClick} data-action="video-call">
+          <li className={cx({ "dropdown-menu-item": true,
+                              "disabled": this.props.blocked })}
+              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 })}
+          <li className={cx({ "dropdown-menu-item": true,
+                              "disabled": this.props.blocked })}
               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" />
@@ -383,20 +386,24 @@ loop.contacts = (function(_, mozL10n) {
           // 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);
+          if (!contact.blocked) {
+            navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_VIDEO);
+          }
           break;
         case "audio-call":
-          navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY);
+          if (!contact.blocked) {
+            navigator.mozLoop.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY);
+          }
           break;
         default:
           console.error("Unrecognized action: " + actionName);
           break;
       }
     },
 
     sortContacts: function(contact1, contact2) {
--- a/browser/components/loop/test/mochitest/browser_LoopContacts.js
+++ b/browser/components/loop/test/mochitest/browser_LoopContacts.js
@@ -11,27 +11,37 @@ XPCOMUtils.defineLazyServiceGetter(this,
 const kContacts = [{
   id: 1,
   name: ["Ally Avocado"],
   email: [{
     "pref": true,
     "type": ["work"],
     "value": "ally@mail.com"
   }],
+  tel: [{
+    "pref": true,
+    "type": ["mobile"],
+    "value": "+31-6-12345678"
+  }],
   category: ["google"],
   published: 1406798311748,
   updated: 1406798311748
 },{
   id: 2,
   name: ["Bob Banana"],
   email: [{
     "pref": true,
     "type": ["work"],
     "value": "bob@gmail.com"
   }],
+  tel: [{
+    "pref": true,
+    "type": ["mobile"],
+    "value": "+1-214-5551234"
+  }],
   category: ["local"],
   published: 1406798311748,
   updated: 1406798311748
 }, {
   id: 3,
   name: ["Caitlin Cantaloupe"],
   email: [{
     "pref": true,
@@ -420,16 +430,55 @@ add_task(function* () {
   let contacts = yield promiseLoadContacts();
   for (let i = 0, l = contacts.length; i < l; ++i) {
     compareContacts(contacts[i], kContacts[i]);
   }
 
   LoopStorage.switchDatabase();
   Assert.equal(LoopStorage.databaseName, "default", "The active partition should have changed");
 
-  LoopContacts.getAll(function(err, contacts) {
-    Assert.equal(err, null, "There shouldn't be an error");
+  contacts = yield LoopContacts.promise("getAll");
+  for (let i = 0, l = contacts.length; i < l; ++i) {
+    compareContacts(contacts[i], kContacts[i]);
+  }
+});
+
+// Test searching for contacts.
+add_task(function* () {
+  yield promiseLoadContacts();
+
+  let contacts = yield LoopContacts.promise("search", {
+    q: "bob@gmail.com"
+  });
+  Assert.equal(contacts.length, 1, "There should be one contact found");
+  compareContacts(contacts[0], kContacts[1]);
+
+  // Test searching by name.
+  contacts = yield LoopContacts.promise("search", {
+    q: "Ally Avocado",
+    field: "name"
+  });
+  Assert.equal(contacts.length, 1, "There should be one contact found");
+  compareContacts(contacts[0], kContacts[0]);
 
-    for (let i = 0, l = contacts.length; i < l; ++i) {
-      compareContacts(contacts[i], kContacts[i]);
-    }
+  // Test searching for multiple contacts.
+  contacts = yield LoopContacts.promise("search", {
+    q: "google",
+    field: "category"
+  });
+  Assert.equal(contacts.length, 2, "There should be two contacts found");
+
+  // Test searching for telephone numbers.
+  contacts = yield LoopContacts.promise("search", {
+    q: "+31612345678",
+    field: "tel"
   });
+  Assert.equal(contacts.length, 1, "There should be one contact found");
+  compareContacts(contacts[0], kContacts[0]);
+
+  // Test searching for telephone numbers without prefixes.
+  contacts = yield LoopContacts.promise("search", {
+    q: "5551234",
+    field: "tel"
+  });
+  Assert.equal(contacts.length, 1, "There should be one contact found");
+  compareContacts(contacts[0], kContacts[1]);
 });