Bug 857049 - Port Twitter API v1.1 from Instantbird to c-c, r=florian,mconley, a=Standard8
authorPatrick Cloke <clokep@gmail.com>
Sat, 16 Mar 2013 14:11:57 -0400
changeset 13664 a63b6053d11caf8b02cda76fa9d3caa107b7b96b
parent 13663 1834c9586d8fd23cea86295013c25ffdc228ddf7
child 13665 b08adc6d78ef8f67e7d083b6417510f098ed8229
child 13667 00a16fecda9678dc097baba7cad6e2b5e6c861e2
child 13668 cfeb6ef7fc796a8d1c55ba591b9698d644489c1b
push id53
push userbugzilla@standard8.plus.com
push dateThu, 09 May 2013 20:21:13 +0000
reviewersflorian, mconley, Standard8
bugs857049
Bug 857049 - Port Twitter API v1.1 from Instantbird to c-c, r=florian,mconley, a=Standard8
chat/modules/http.jsm
chat/protocols/twitter/twitter.js
mail/base/modules/http.jsm
--- a/chat/modules/http.jsm
+++ b/chat/modules/http.jsm
@@ -1,20 +1,24 @@
 /* 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/. */
 
-const EXPORTED_SYMBOLS = ["doXHRequest"];
+const EXPORTED_SYMBOLS = ["doXHRequest", "percentEncode"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource:///modules/imXPCOMUtils.jsm");
 
 initLogModule("xhr", this);
 
+// Strictly follow RFC 3986 when encoding URI components.
+function percentEncode(aString)
+  encodeURIComponent(aString).replace(/[!'()]/g, escape).replace(/\*/g, "%2A");
+
 function doXHRequest(aUrl, aHeaders, aPOSTData, aOnLoad, aOnError, aThis) {
   let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
               .createInstance(Ci.nsIXMLHttpRequest);
   xhr.mozBackgroundRequest = true; // no error dialogs
   xhr.open(aPOSTData ? "POST" : "GET", aUrl);
   xhr.channel.loadFlags = Ci.nsIChannel.LOAD_ANONYMOUS | // don't send cookies
                           Ci.nsIChannel.LOAD_BYPASS_CACHE |
                           Ci.nsIChannel.INHIBIT_CACHING;
@@ -60,16 +64,16 @@ function doXHRequest(aUrl, aHeaders, aPO
       xhr.setRequestHeader(header[0], header[1]);
     });
   }
 
   let POSTData = "";
   if (aPOSTData) {
     xhr.setRequestHeader("Content-Type",
                          "application/x-www-form-urlencoded; charset=utf-8");
-    POSTData = aPOSTData.map(function(p) p[0] + "=" + encodeURIComponent(p[1]))
+    POSTData = aPOSTData.map(function(p) p[0] + "=" + percentEncode(p[1]))
                         .join("&");
   }
 
   LOG("sending request to " + aUrl + " (POSTData = " + POSTData + ")");
   xhr.send(POSTData);
   return xhr;
 }
--- a/chat/protocols/twitter/twitter.js
+++ b/chat/protocols/twitter/twitter.js
@@ -171,17 +171,17 @@ Conversation.prototype = {
   },
   _parseError: function(aData) {
     let error = "";
     try {
       let data = JSON.parse(aData);
       if ("error" in data)
         error = data.error;
       else if ("errors" in data)
-        error = data.errors.split("\n")[0];
+        error = data.errors[0].message;
       if (error)
         error = "(" + error + ")";
     } catch(e) {}
     return error;
   },
   parseTweet: function(aTweet) {
     let text = aTweet.text;
     let entities = {};
@@ -400,20 +400,16 @@ Account.prototype = {
       ["oauth_consumer_key", this.consumerKey],
       ["oauth_nonce", nonce],
       ["oauth_signature_method", "HMAC-SHA1"],
       ["oauth_token", this.token],
       ["oauth_timestamp", Math.floor(((new Date()).getTime()) / 1000)],
       ["oauth_version", "1.0"]
     ]);
 
-    function percentEncode(aString)
-      encodeURIComponent(aString).replace(/\!|\*|\'|\(|\)/g, function(m)
-        ({"!": "%21", "*": "%2A", "'": "%27", "(": "%28", ")": "%29"}[m]))
-
     let dataParams = [];
     let url = /^https?:/.test(aUrl) ? aUrl : this.baseURI + aUrl;
     let urlSpec = url;
     let queryIndex = url.indexOf("?");
     if (queryIndex != -1) {
       urlSpec = url.slice(0, queryIndex);
       dataParams = url.slice(queryIndex + 1).split("&")
                       .map(function(p) p.split("=").map(percentEncode));
@@ -461,52 +457,51 @@ Account.prototype = {
     });
     return result;
   },
 
   tweet: function(aMsg, aInReplyToId, aOnSent, aOnError, aThis) {
     let POSTData = [["status", aMsg]];
     if (aInReplyToId)
       POSTData.push(["in_reply_to_status_id", aInReplyToId]);
-    this.signAndSend("1/statuses/update.json?include_entities=1", null,
-                     POSTData, aOnSent, aOnError, aThis);
+    this.signAndSend("1.1/statuses/update.json", null, POSTData, aOnSent,
+                     aOnError, aThis);
   },
   reTweet: function(aTweet, aOnSent, aOnError, aThis) {
-    let url =
-      "1/statuses/retweet/" + aTweet.id_str + ".json?include_entities=1";
+    let url = "1.1/statuses/retweet/" + aTweet.id_str + ".json";
     this.signAndSend(url, null, [], aOnSent, aOnError, aThis);
   },
   destroy: function(aTweet, aOnSent, aOnError, aThis) {
-    let url =
-      "1/statuses/destroy/" + aTweet.id_str + ".json?include_entities=1";
+    let url = "1.1/statuses/destroy/" + aTweet.id_str + ".json";
     this.signAndSend(url, null, [], aOnSent, aOnError, aThis);
   },
 
   _friends: null,
   isFriend: function(aUser) {
-    if (!("id" in aUser) || // users from search API tweets don't have an id.
+    if (!("id_str" in aUser) ||
         !this._friends) // null until data is received from the user stream.
       return null;
     //XXX Good enough for now, but if we ever call this from a loop,
     // we should keep this._friends sorted and do a binary search.
-    return this._friends.indexOf(aUser.id) != -1;
+    return this._friends.indexOf(aUser.id_str) != -1;
   },
   follow: function(aUserName) {
-    this.signAndSend("1/friendships/create.json", null,
+    this.signAndSend("1.1/friendships/create.json", null,
                      [["screen_name", aUserName]]);
   },
   stopFollowing: function(aUserName) {
     // friendships/destroy will return the user in case of success.
     // Error cases would return a non 200 HTTP code and not call our callback.
-    this.signAndSend("1/friendships/destroy.json", null,
+    this.signAndSend("1.1/friendships/destroy.json", null,
                      [["screen_name", aUserName]], function(aData, aXHR) {
       let user = JSON.parse(aData);
-      if (!("id" in user))
+      if (!("id_str" in user))
         return; // Unexpected response...
-      this._friends = this._friends.filter(function(id) id != user.id);
+      this._friends =
+        this._friends.filter(function(id_str) id_str != user.id_str);
       let date = aXHR.getResponseHeader("Date");
       this.timeline.systemMessage(_("event.unfollow", user.screen_name), false,
                                   new Date(date) / 1000);
     }, null, this);
   },
   addBuddy: function(aTag, aName) {
     this.follow(aName);
   },
@@ -522,32 +517,34 @@ Account.prototype = {
       // croak on our request.
       if (/^\d+$/.test(lastMsgId)) {
         lastMsgParam = "&since_id=" + lastMsgId;
         this._lastMsgId = lastMsgId;
       }
       else
         WARN("invalid value for the lastMessageId preference: " + lastMsgId);
     }
-    let getParams = "?include_entities=1&count=200" + lastMsgParam;
+    let getParams = "?count=200" + lastMsgParam;
     this._pendingRequests = [
-      this.signAndSend("1/statuses/home_timeline.json" + getParams, null, null,
-                       this.onTimelineReceived, this.onTimelineError, this),
-      this.signAndSend("1/statuses/mentions.json" + getParams, null, null,
-                       this.onTimelineReceived, this.onTimelineError, this)
+      this.signAndSend("1.1/statuses/home_timeline.json" + getParams, null,
+                       null, this.onTimelineReceived, this.onTimelineError,
+                       this),
+      this.signAndSend("1.1/statuses/mentions_timeline.json" + getParams, null,
+                       null, this.onTimelineReceived, this.onTimelineError,
+                       this)
     ];
 
     let track = this.getString("track");
     if (track) {
       let trackQuery = track.split(",").map(encodeURIComponent).join(" OR ");
-      getParams = "?q=" + trackQuery + lastMsgParam;
-      let url = "http://search.twitter.com/search.json" + getParams;
-      this._pendingRequests.push(doXHRequest(url, null, null,
-                                             this.onSearchResultsReceived,
-                                             this.onTimelineError, this));
+      getParams = "?q=" + trackQuery + lastMsgParam + "&count=100";
+      let url = "1.1/search/tweets.json" + getParams;
+      this._pendingRequests.push(
+        this.signAndSend(url, null, null, this.onTimelineReceived,
+                         this.onTimelineError, this, null));
     }
   },
 
   get timeline() this._timeline || (this._timeline = new Conversation(this)),
   displayMessages: function(aMessages) {
     let lastMsgId = this._lastMsgId;
     for each (let tweet in aMessages) {
       if (!("user" in tweet) || !("text" in tweet) || !("id_str" in tweet) ||
@@ -573,69 +570,16 @@ Account.prototype = {
 
   onTimelineError: function(aError, aResponseText, aRequest) {
     ERROR(aError);
     if (aRequest.status == 401)
       ++this._timelineAuthError;
     this._doneWithTimelineRequest(aRequest);
   },
 
-  onSearchResultsReceived: function(aData, aRequest) {
-    // Parse the returned data
-    let data = JSON.parse(aData);
-    // Fix the results from the search API to match those of the REST API.
-    // See bug 1053.
-    if ("results" in data) {
-      data = data.results;
-      for each (let tweet in data) {
-        if (!("user" in tweet) && "from_user" in tweet) {
-          tweet.user = {screen_name: tweet.from_user,
-                        profile_image_url: tweet.profile_image_url};
-        }
-        if (!("entities" in tweet)) {
-          tweet.entities = {};
-          let text = tweet.text;
-          let match;
-          let hashTags = [];
-          // The \B (non-word boundary) ensures that the character
-          // right before the # is not a character commonly found in
-          // words. This should prevent us from matching part of URLs.
-          // For the text of the hashtag, the official ruby(!) implementation
-          // matches an arbitrary number of alphanumeric (or underscore)
-          // characters, but with at least one non-digit character
-          // (not necessarily at the beginning of the tag).
-          let re = /\B[##](\w*[A-Za-z_]\w*)/g;
-          while ((match = re.exec(text))) {
-            hashTags.push({text: match[1],
-                           indices: [re.lastIndex - match[0].length,
-                                     re.lastIndex]});
-          }
-          if (hashTags.length)
-            tweet.entities.hashtags = hashTags;
-
-          let mentions = [];
-          // The \B is here to avoid matching parts of email addresses.
-          // For the text of the username, the official ruby implementation
-          // matches 1 to 20 alphanumeric (or underscore) characters.
-          re = /\B[@@](\w{1,20})/g;
-          while ((match = re.exec(text))) {
-            mentions.push({name: "", screen_name: match[1],
-                           indices: [re.lastIndex - match[0].length,
-                                     re.lastIndex]});
-          }
-          if (mentions.length)
-            tweet.entities.user_mentions = mentions;
-        }
-      }
-    }
-    this._timelineBuffer = this._timelineBuffer.concat(data);
-
-    this._doneWithTimelineRequest(aRequest);
-  },
-
   onTimelineReceived: function(aData, aRequest) {
     this._timelineBuffer = this._timelineBuffer.concat(JSON.parse(aData));
     this._doneWithTimelineRequest(aRequest);
   },
 
   _doneWithTimelineRequest: function(aRequest) {
     this._pendingRequests =
       this._pendingRequests.filter(function (r) r !== aRequest);
@@ -691,19 +635,19 @@ Account.prototype = {
   sortByDate: function(a, b)
     (new Date(a["created_at"])) - (new Date(b["created_at"])),
 
   _streamingRequest: null,
   _pendingData: "",
   openStream: function() {
     let track = this.getString("track");
     this._streamingRequest =
-      this.signAndSend("https://userstream.twitter.com/2/user.json",
-                       null, track ? [["track", track]] : [],
-                       this.openStream, this.onStreamError, this);
+      this.signAndSend("https://userstream.twitter.com/1.1/user.json", null,
+                       track ? [["track", track]] : [], this.openStream,
+                       this.onStreamError, this);
     this._streamingRequest.responseType = "moz-chunked-text";
     this._streamingRequest.onprogress = this.onDataAvailable.bind(this);
   },
   onStreamError: function(aError) {
     delete this._streamingRequest;
     this.gotDisconnected(Ci.prplIAccount.ERROR_NETWORK_ERROR, aError);
   },
   onDataAvailable: function(aRequest) {
@@ -723,21 +667,21 @@ Account.prototype = {
       }
       if ("text" in msg) {
         this.displayMessages([msg]);
         // If the message is from us, set it as the topic.
         if (("user" in msg) && (msg.user.screen_name == this.name))
           this.timeline.setTopic(msg.text, msg.user.screen_name);
       }
       else if ("friends" in msg)
-        this._friends = msg.friends;
+        this._friends = msg.friends.map(function(aId) aId.toString());
       else if (("event" in msg) && msg.event == "follow") {
         let user, event;
         if (msg.source.screen_name == this.name) {
-          this._friends.push(msg.target.id);
+          this._friends.push(msg.target.id_str);
           user = msg.target;
           event = "follow";
         }
         else if (msg.target.screen_name == this.name) {
           user = msg.source;
           event = "followed";
         }
         if (user) {
@@ -940,17 +884,17 @@ Account.prototype = {
 
   onRequestedInfoReceived: function(aData) {
     let user = JSON.parse(aData);
     this._userInfo[user.screen_name] = user;
     this.requestBuddyInfo(user.screen_name);
   },
   requestBuddyInfo: function(aBuddyName) {
     if (!hasOwnProperty(this._userInfo, aBuddyName)) {
-      this.signAndSend("1/users/show.json?screen_name=" + aBuddyName, null,
+      this.signAndSend("1.1/users/show.json?screen_name=" + aBuddyName, null,
                        null, this.onRequestedInfoReceived, null, this);
       return;
     }
 
     let userInfo = this._userInfo[aBuddyName];
 
     // List of the names of the info to actually show in the tooltip and
     // optionally a transform function to apply to the value.
--- a/mail/base/modules/http.jsm
+++ b/mail/base/modules/http.jsm
@@ -1,18 +1,22 @@
 /* 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/. */
 
-var EXPORTED_SYMBOLS = ["doXHRequest"];
+var EXPORTED_SYMBOLS = ["doXHRequest", "percentEncode"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
+// Strictly follow RFC 3986 when encoding URI components.
+function percentEncode(aString)
+  encodeURIComponent(aString).replace(/[!'()]/g, escape).replace(/\*/g, "%2A");
+
 function doXHRequest(aUrl, aHeaders, aPOSTData, aOnLoad, aOnError, aThis, aMethod) {
-  var xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+  let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
               .createInstance(Ci.nsIXMLHttpRequest);
   xhr.mozBackgroundRequest = true; // no error dialogs
   xhr.open(aMethod || (aPOSTData ? "POST" : "GET"), aUrl);
   xhr.channel.loadFlags = Ci.nsIChannel.LOAD_ANONYMOUS | // don't send cookies
                           Ci.nsIChannel.LOAD_BYPASS_CACHE |
                           Ci.nsIChannel.INHIBIT_CACHING;
   xhr.onerror = function(aProgressEvent) {
     if (aOnError) {
@@ -55,15 +59,15 @@ function doXHRequest(aUrl, aHeaders, aPO
       xhr.setRequestHeader(header[0], header[1]);
     });
   }
 
   let POSTData = aPOSTData || "";
   if (Array.isArray(POSTData)) {
     xhr.setRequestHeader("Content-Type",
                          "application/x-www-form-urlencoded; charset=utf-8");
-    POSTData = aPOSTData.map(function(p) p[0] + "=" + encodeURIComponent(p[1]))
+    POSTData = aPOSTData.map(function(p) p[0] + "=" + percentEncode(p[1]))
                         .join("&");
   }
 
   xhr.send(POSTData);
   return xhr;
 }