home *** CD-ROM | disk | FTP | other *** search
/ Maximum CD 2011 February / maximum-cd-2011-02.iso / DiscContents / digsby_setup85.exe / lib / plugins / twitter / res / twitter.js < prev    next >
Encoding:
JavaScript  |  2010-11-24  |  88.9 KB  |  2,712 lines

  1. var TRENDS_UPDATE_MINS = 60;
  2. var IDLE_THRESHOLD_MS = 10 * 60 * 1000;
  3.  
  4. var INVITE_TEXT = "Hey, I've been using Digsby and thought you might find it useful. Check out the demo video: ";
  5. var INVITE_URL  = "http://bit.ly/clMDW5";
  6.  
  7. // javascript's RegExp \w character class doesn't include common extended characters. this string
  8. // includes some common latin characters, at least.
  9. var wordchars = '\\w└╚╠╥┘αΦ∞≥∙┴╔═╙┌▌ßΘφ≤·²┬╩╬╘█ΓΩε⌠√├╤╒π±⌡─╦╧╓▄Σδ∩÷ⁿí┐τ╟▀╪°┼σ╞µ▐■╨≡';
  10.  
  11. /* linkify @usernames */
  12. var atify_regex = new RegExp('(?:(?:^@([\\w]+))|(?:([^\\w])@([' + wordchars + ']+)))(/[' + wordchars + ']+)?', 'g');
  13.  
  14. var atifyOnClick = 'onclick="javascript:return tweetActions.openUser(this);"';
  15.  
  16. function atify(s) {
  17.     return s.replace(atify_regex, function(m, sn1, extra, sn2, listName) {
  18.         var screenname = (sn1 || sn2) + (listName ? listName : '');
  19.         return (extra || '') + '@<a class="userLink" href="http://twitter.com/' + screenname + '"' +
  20.             ' ' + atifyOnClick +
  21.             '>' + screenname + '</a>';
  22.     });
  23. }
  24.  
  25. /* linkify #hastags */
  26. var hashifyRegex = new RegExp('(?:(?:^)|(?:([^/&' + wordchars + '])))(#([' + wordchars + ']{2,}))', 'g');
  27.  
  28. function hashify(s) {
  29.     return s.replace(hashifyRegex, function(m, extra, outerSearch, search) {
  30.          return (extra || '') + '<a href="http://search.twitter.com/search?q=%23' + search + '">' + outerSearch + '</a>';
  31.     });
  32. }
  33.  
  34. function twitterLinkify(s) {
  35.     return atify(hashify(linkify(s)));
  36. }
  37.  
  38. var infoColumns = [
  39.     ['key', 'TEXT'],
  40.     ['value', 'TEXT']
  41. ];
  42.  
  43. // detect malformed databases and ask the account to delete all local data if we do
  44. function executeSql(tx, query, args, success, error) {
  45.     function _error(tx, err) {
  46.         if (err.message === 'database disk image is malformed')
  47.             D.notify('corrupted_database');
  48.         else
  49.             return error(tx, err);
  50.     }
  51.  
  52.     return tx.executeSql(query, args, success, _error);
  53. }
  54.  
  55. var tweetColumns = [
  56.     // [JSON name, sqlite datatype]
  57.     ['id',         'TEXT UNIQUE'],
  58.     ['created_at', 'TEXT'],
  59.     ['text',       'TEXT'],
  60.     ['source',     'TEXT'],
  61.     ['truncated',  'TEXT'],
  62.     ['in_reply_to_status_id', 'TEXT'],
  63.     ['in_reply_to_user_id',   'INTEGER'],
  64.     ['favorited',  'BOOLEAN'],
  65.     ['in_reply_to_screen_name', 'TEXT'],
  66.     ['user',       'INTEGER'],
  67.  
  68.     // custom
  69.     ['read',       'INTEGER'], // is read
  70.     ['mention',    'BOOLEAN'], // has @username in text
  71.     ['search',     'TEXT'],    // came from a search
  72.  
  73.     // search tweets are returned with just the username, not an ID, so for these
  74.     // tweets we only have from_user and profile_image_url
  75.     ['from_user',  'TEXT'],
  76.     ['profile_image_url', 'TEXT']
  77. ];
  78.  
  79. var directColumns = [
  80.     ['id',           'TEXT UNIQUE'],
  81.     ['sender_id',    'INTEGER'],
  82.     ['text',         'TEXT'],
  83.     ['recipient_id', 'INTEGER'],
  84.     ['created_at',   'TEXT'],
  85.  
  86.     ['read',         'INTEGER'],
  87. ];
  88.  
  89. var userColumns = [
  90.     ['id',          'INTEGER UNIQUE'],
  91.     ['name',        'TEXT'],
  92.     ['screen_name', 'TEXT'],
  93.     ['location',    'TEXT'],
  94.     ['description', 'TEXT'],
  95.     ['profile_image_url', 'TEXT'],
  96.     ['url',         'TEXT'],
  97.     ['protected',   'TEXT'],
  98.     ['followers_count', 'INTEGER'],
  99.     ['profile_background_color', 'TEXT'],
  100.     ['profile_text_color', 'TEXT'],
  101.     ['profile_link_color', 'TEXT'],
  102.     ['profile_sidebar_fill_color', 'TEXT'],
  103.     ['profile_sidebar_border_color', 'TEXT'],
  104.     ['friends_count',  'INTEGER'],
  105.     ['created_at',     'TEXT'],
  106.     ['favourites_count', 'INTEGER'],
  107.     ['utc_offset',     'INTEGER'],
  108.     ['time_zone',      'TEXT'],
  109.     ['profile_background_image_url', 'TEXT'],
  110.     ['profile_background_tile', 'TEXT'],
  111.     ['statuses_count', 'INTEGER'],
  112.     ['notifications',  'TEXT'],
  113.     ['following',      'TEXT']
  114. ];
  115.  
  116. var tweetsByTime = cmpByKey('created_at_ms');
  117. var tweetsById = cmpByKey('id');
  118.  
  119. function twitterPageLoaded(window)
  120. {
  121.     guard(function() {
  122.     // TODO: account should not be a global on the window.
  123.     var account = window.opener.account;
  124.     account.window = window;
  125.     window.db = window.opener.db;
  126.     window.timeline.account = account;
  127.     var timeline = account.timeline = window.timeline;
  128.     timeline.dataMap = account.tweets;
  129.  
  130.     // update timestamps every minute.
  131.     window.timestampTimer = window.setInterval(function() { timeline.updateTimestamps(); }, 1000 * 60);
  132.  
  133.     // use a temporary function on the window here hardcoded into feed.html's <body onunload> handler
  134.     // until http://dev.jquery.com/ticket/4791 is fixed (jquery child window unloads get registered
  135.     // on parent windows)
  136.     window.feedUnload = function() {
  137.         console.log('unload handler called');
  138.         account.recordReadTweets();
  139.     };
  140.  
  141.     var feedToOpen = window.opener.feedToOpen || 'timeline';
  142.  
  143.     //console.warn('calling changeView from twitterPageLoaded');
  144.     account.changeView(feedToOpen);
  145.     account.notifyFeeds();
  146.     delete window.opener.feedToOpen;
  147.     });
  148. }
  149.  
  150. function TwitterAccount(username, password, opts) {
  151.     if (!username)
  152.         throw "Need username";
  153.  
  154.     //console.warn('TwitterAccount(' + username + ', ' + password + ', ' + oauthToken + ', ' + oauthConsumerSecret + ')');
  155.  
  156.     var self = this;
  157.     this.showAllRealtime = false;
  158.     this.clients = [];
  159.     this.username = username;
  160.     //this.selfScreenNameLower = username.toLowerCase();
  161.     this.password = password;
  162.  
  163.     // OAUTH
  164.     if (opts.oauthTokenKey)
  165.         this.oauth = {
  166.             tokenKey: opts.oauthTokenKey,
  167.             tokenSecret: opts.oauthTokenSecret,
  168.             consumerKey:  opts.oauthConsumerKey,
  169.             consumerSecret: opts.oauthConsumerSecret
  170.         };
  171.  
  172.     this.users = {};   // {user id: user object}
  173.  
  174.     this.sql = {
  175.         'tweets':  new SQLDesc('Tweets',  tweetColumns),
  176.         'users':   new SQLDesc('Users',   userColumns),
  177.         'info':    new SQLDesc('Info',    infoColumns),
  178.         'directs': new SQLDesc('Directs', directColumns)
  179.     };
  180. }
  181.  
  182. function dropAllTables() {
  183.     var tables = objectKeys(window.account.sql);
  184.  
  185.     window.db.transaction(function(tx) {
  186.         console.log('dropping tables: ' + tables.join(', '));
  187.         $.each(tables, function (i, table) {
  188.             executeSql(tx, 'drop table if exists ' + table);
  189.         });
  190.     }, function(error) { console.error(error.message); });
  191. }
  192.  
  193.  
  194. function tweetsEqual(t1, t2) { return t1.id === t2.id; }
  195.  
  196. function errorFunc(msg) {
  197.     return function (err) {
  198.         var extra = '';
  199.         if (err !== undefined && err.message !== undefined)
  200.             extra = ': ' + err.message;
  201.         console.error(msg + extra);
  202.     };
  203. }
  204.  
  205. TwitterAccount.prototype = {
  206.     timerMs: 1000 * 60,
  207.     apiRoot: 'https://api.twitter.com/1/',
  208.     searchApiRoot: 'http://search.twitter.com/',
  209.  
  210.     showTimelinePopup: function(n) {
  211.         var items = this.feeds.timeline.items;
  212.         if (n !== undefined)
  213.             items = items.slice(0, n);
  214.  
  215.         showPopupsForTweets(this, items);
  216.     },
  217.  
  218.     stopTimers: function(reason) {
  219.         console.warn('stopTimers:' + reason);
  220.         if (this.updateTimer)
  221.             clearInterval(this.updateTimer);
  222.     },
  223.  
  224.     maybeUpdate: function(forceUpdate, sources) {
  225.         var self = this;
  226.         var now = new Date().getTime();
  227.         var srcs;
  228.         var checkIdle = false;
  229.  
  230.         if (sources) {
  231.             srcs = sources;
  232.         } else if (forceUpdate) {
  233.             console.warn('forcing update');
  234.             srcs = $.map(self.sources, function(s) { return s.feed.autoUpdates ? s : null; });
  235.         } else {
  236.             checkIdle = true;
  237.             // only update sources whose needsUpdate returns true
  238.             srcs = $.map(self.sources, function(s) { return s.needsUpdate(now) ? s : null; });
  239.         }
  240.  
  241.         if (srcs.length === 0)
  242.             return;
  243.  
  244.         function _doUpdate() {
  245.             if (self.returnFromIdleTimer) {
  246.                 clearInterval(self.returnFromIdleTimer);
  247.                 self.returnFromIdleTimer = undefined;
  248.             }
  249.  
  250.             // any successful update triggers online state
  251.             function success() {
  252.                 self.didUpdate = true;
  253.                 self.notifyClients('didReceiveUpdates');
  254.             }
  255.             console.debug('UpdateNotification being created with ' + srcs.length);
  256.             var updateNotification = new UpdateNotification(self, srcs.length, {
  257.                 success: success,
  258.                 onDone: function(tweets) {
  259.                     self.notifyClients('didReceiveWholeUpdate');
  260.                 },
  261.             });
  262.  
  263.             var markRead = false;
  264.             if (self.firstUpdate) {
  265.                 self.firstUpdate = false;
  266.                 markRead = true;
  267.             }
  268.  
  269.             for (var c = 0; c < srcs.length; ++c) {
  270.                 var src = srcs[c];
  271.                 if (markRead)
  272.                     src.markNextUpdateAsRead();
  273.                 src.update(updateNotification);
  274.             }
  275.         }
  276.  
  277.         if (!checkIdle) {
  278.             _doUpdate();
  279.             return;
  280.         }
  281.  
  282.         D.rpc('get_idle_time', {}, function(args) {
  283.             if (args.idleTime > IDLE_THRESHOLD_MS) {
  284.                 if (!self.returnFromIdleTimer) {
  285.                     // once idle, check idle time every 10 seconds so that
  286.                     // we can update soon after you come back.
  287.                     self.returnFromIdleTimer = setInterval(function() {
  288.                         self.maybeUpdate();
  289.                     }, 10 * 1000);
  290.                 }
  291.             } else _doUpdate();
  292.         }, _doUpdate);
  293.     },
  294.  
  295.     discardOldTweets: function() {
  296.         var self = this,
  297.             idsToDelete = [],
  298.             usersToKeep = {};
  299.  
  300.         var doc, toInsertMap;
  301.         if (this.timeline) {
  302.             doc = this.timeline.container.ownerDocument;
  303.             console.debug('** discardOldTweets this.timeline.toInsert: ' + this.timeline.toInsert);
  304.             if (this.timeline.toInsertMap)
  305.                 toInsertMap = this.timeline.toInsertMap;
  306.         }
  307.  
  308.         doc = this.timeline ? this.timeline.container.ownerDocument : undefined;
  309.  
  310.         //console.warn('** discardOldTweets toInsertMap: ' + toInsertMap);
  311.  
  312.         function shouldDelete(tweet) {
  313.             if (!doc || !doc.getElementById(tweet.id))
  314.                 if (!tweet.feeds || !objectKeys(tweet.feeds).length)
  315.                     if (!toInsertMap || !(tweet.id in toInsertMap))
  316.                         return true;
  317.         }
  318.  
  319.         function discard(items) {
  320.             // discard references to all tweets not in a feed
  321.             $.each(items, function (i, tweet) {
  322.                 if (shouldDelete(tweet))
  323.                     idsToDelete.push(tweet.id);
  324.                 else {
  325.                     $.each(['user', 'recipient_id', 'sender_id'], function (i, attr) {
  326.                         if (tweet[attr])
  327.                             usersToKeep[tweet[attr]] = true;
  328.                     });
  329.                 }
  330.             });
  331.  
  332.             $.each(idsToDelete, function (i, id) {
  333.                 //console.log('DELETING ' + id + ' ' + items[id].text + ' ' + objectLength(items[id].feeds))
  334.                 delete items[id];
  335.             });
  336.         }
  337.  
  338.         discard(this.tweets);
  339.  
  340.         // discard all users not referenced by tweets or directs or groups
  341.  
  342.         $.each(this.getUsersToKeep(), function(i, id) {
  343.             usersToKeep[id] = true;
  344.         });
  345.  
  346.         var usersToDelete = [];
  347.         $.each(this.users, function (id, user) {
  348.             if (user.screen_name.toLowerCase() !== this.selfScreenNameLower)
  349.                 if (!(id in usersToKeep))
  350.                     usersToDelete.push(id);
  351.         });
  352.  
  353.         if (usersToDelete.length) {
  354.             $.each(usersToDelete, function (i, id) { delete self.users[id]; });
  355.         }
  356.     },
  357.  
  358.     forceUpdate: function() {
  359.         this.maybeUpdate(true);
  360.     },
  361.  
  362.     setupTimers: function() {
  363.         var self = this;
  364.         this.updateTimer = setInterval(function() { self.maybeUpdate(); }, this.timerMs);
  365.         this.maybeUpdate();
  366.  
  367.         // update trends periodically.
  368.         this.trendsTimer = setInterval(function() { self.getTrends(); },
  369.                                        1000 * 60 * TRENDS_UPDATE_MINS);
  370.     },
  371.  
  372.     initialize: function(feeds, accountopts) {
  373.         var self = this;
  374.  
  375.         if (accountopts) {
  376.             console.debug(JSON.stringify(accountopts));
  377.             if (accountopts.timeCorrectionSecs !== undefined) {
  378.                 var diffMs = accountopts.timeCorrectionSecs * 1000;
  379.                 var d = new Date(new Date().getTime() - diffMs);
  380.                 receivedCorrectTimestamp(d, true);
  381.             }
  382.         }
  383.  
  384.         function success(tx) {
  385.             self.initializeFeeds(tx, feeds, accountopts, function() {
  386.                 if (self.offlineMode) {
  387.                     self.didUpdate = true;
  388.                     self.notifyClients('didReceiveUpdates');
  389.                     self.notifyFeeds();
  390.                     self.doNotifyUnread();
  391.                 } else {
  392.                     self.getFollowing(function() {
  393.                         // we don't know which users to discard until we know who we're following.
  394.                         self.discardOldUsers();
  395.                     });
  396.                     self.getTrends();
  397.                     self.setupTimers();
  398.                     self.notifyFeeds();
  399.                 }
  400.             });
  401.         }
  402.  
  403.         function error(tx, error) {
  404.             console.error('error creating tables: ' + error.message);
  405.         }
  406.  
  407.         account.setupSql(success, error);
  408.     },
  409.  
  410.     refreshFeedItems: function(feed) {
  411.         feed.removeAllItems();
  412.         this.addAllSorted(feed, this.timeline && this.timeline.feed === feed);
  413.     },
  414.  
  415.     addAllSorted: function(feed, deleted) {
  416.         var tweets = objectValues(this.tweets);
  417.         tweets.sort(tweetsByTime);
  418.  
  419.         console.debug('##########\nsorting all tweets for addSorted on ' + feed + ' ' + tweets.length + ' tweets');
  420.         var scroll = this.timeline && this.timeline.feed === this.feeds.timeline;
  421.         feed.addSorted(tweets, undefined, true, {
  422.             scroll: scroll,
  423.             setSorted: deleted,
  424.             account: this
  425.         });
  426.     },
  427.  
  428.     deleteFeed: function(opts) {
  429.         var self = this, name = opts.feedName;
  430.         if (!name) return;
  431.  
  432.         console.log('deleteFeed: ' + name);
  433.  
  434.         var didFind = false;
  435.         $.each(this.customFeeds, function (i, feed) {
  436.             if (feed.name !== name) return;
  437.             didFind = true;
  438.  
  439.             if (feed.source) {
  440.                 if (!arrayRemove(self.sources, feed.source))
  441.                     console.error('did not remove source ' + feed.source);
  442.             }
  443.  
  444.             self.customFeeds.splice(i, 1);
  445.  
  446.             // when deleting a custom feed, the timeline may change
  447.             // b/c of filtering and merging
  448.             var timeline = self.feeds.timeline;
  449.             if (timeline && feed !== timeline) {
  450.                 var deleted = timeline.feedDeleted(feed.serialize(), self.customFeeds);
  451.                 self.addAllSorted(timeline, deleted);
  452.             }
  453.  
  454.             delete self.feeds[name];
  455.             self.notifyFeeds();
  456.             self.doNotifyUnread();
  457.             return false;
  458.         });
  459.  
  460.         if (!didFind) console.warn('deleteFeed ' + name + ' did not find a feed to delete');
  461.     },
  462.  
  463.     uniqueFeedName: function(type) {
  464.         var name;
  465.         var i = 1;
  466.         do { name = type + ':' + i++;
  467.         } while (name in this.feeds);
  468.         return name;
  469.     },
  470.  
  471.     editFeed: function(feedDesc) {
  472.         var name = feedDesc.name; assert(name);
  473.         var feed = this.feeds[name]; assert(feed);
  474.         var timeline = this.feeds.timeline;
  475.  
  476.         timeline.feedDeleted(feed.serialize(), this.customFeeds);
  477.  
  478.         var needsUpdate = feed.updateOptions(feedDesc);
  479.  
  480.         timeline.feedAdded(this, feed.serialize());
  481.  
  482.         if (needsUpdate) {
  483.             this.refreshFeedItems(feed);
  484.             this.addAllSorted(timeline, true);
  485.         }
  486.  
  487.         this.notifyFeeds();
  488.         if (needsUpdate)
  489.             this.doNotifyUnread();
  490.     },
  491.  
  492.     findFeed: function(feedDesc) {
  493.         console.log('findFeed: ' + JSON.stringify(feedDesc));
  494.  
  495.         if (feedDesc['type'] === 'search' && feedDesc['query']) {
  496.             var foundFeed;
  497.             $.each(this.feeds, function(i, feed) {
  498.                 if (feed.query && feed.query === feedDesc['query']) {
  499.                     foundFeed = feed;
  500.                     return false;
  501.                 }
  502.             });
  503.  
  504.             if (foundFeed)
  505.                 return foundFeed;
  506.         }
  507.     },
  508.  
  509.     getAllGroupIds: function() {
  510.         var allIds = [];
  511.         $.each(this.customFeeds, function (i, feed) {
  512.             var userIds = feed.userIds;
  513.             if (userIds)
  514.                 for (var i = 0; i < userIds.length; ++i)
  515.                     allIds.push(userIds[i]);
  516.         });
  517.         return allIds;
  518.     },
  519.  
  520.     addFeed: function(feedDesc, shouldChangeView) {
  521.         console.debug('addFeed(' + JSON.stringify(feedDesc) + ')');
  522.         console.log(JSON.stringify(feedDesc));
  523.  
  524.         var existingFeed = this.findFeed(feedDesc);
  525.         if (existingFeed) {
  526.             console.log('returning existing feed ' + existingFeed);
  527.             if (this.timeline.feed !== existingFeed)
  528.                 this.changeView(existingFeed['name']);
  529.             return existingFeed;
  530.         }
  531.  
  532.         if (shouldChangeView === undefined)
  533.             shouldChangeView = true;
  534.  
  535.         // assign user created feeds a unique name
  536.         if ((feedDesc.type === 'group' || feedDesc.type === 'search' || feedDesc.type === 'user') &&
  537.             feedDesc.name === undefined)
  538.             feedDesc.name = this.uniqueFeedName(feedDesc.type);
  539.  
  540.         if (feedDesc.type === 'search' && !feedDesc.save)
  541.             D.notify('hook', 'digsby.statistics.twitter.new_search')
  542.  
  543.         var self = this,
  544.             feed = createFeed(this.tweets, feedDesc);
  545.  
  546.         if (shouldChangeView)
  547.             this.addAllSorted(feed);
  548.  
  549.         var source = feed.makeSource(self);
  550.         if (source) {
  551.             this.sources.push(source);
  552.             if (shouldChangeView) {
  553.                 var done = function() { self.timeline.finish(); };
  554.                 var obj = {success: done, error: done};
  555.                 console.log('in addFeed, updating source ' + source + ' now');
  556.                 source.update(obj);
  557.             }
  558.         }
  559.  
  560.         this.feeds[feed.name] = feed;
  561.         this.customFeeds.push(feed);
  562.  
  563.         if (shouldChangeView) {
  564.             this.notifyFeeds();
  565.             this.changeView(feed['name']);
  566.         }
  567.  
  568.         if (feed !== this.feeds.timeline) {
  569.             //console.debug('calling feedAdded: ' + JSON.stringify(feedDesc));
  570.             this.feeds.timeline.feedAdded(this, feedDesc);
  571.         } else
  572.             console.warn('feed is timeline, skipping feedAdded');
  573.  
  574.         return feed;
  575.     },
  576.  
  577.     addGroup: function(feedDesc, shouldChangeView) {
  578.         return this.addFeed(feedDesc, shouldChangeView);
  579.     },
  580.  
  581.     // map of {feedName: [account option name, default minutes to update]}
  582.     account_opts_times: {timeline: ['friends_timeline', 2],
  583.                          directs:  ['direct_messages', 10],
  584.                          mentions: ['replies', 2]},
  585.  
  586.     setAccountOptions: function(opts) {
  587.         if (!this.feeds)
  588.             return;
  589.  
  590.         var invite_url = opts.demovideo_link ? opts.demovideo_link : INVITE_URL;
  591.         this.invite_message = INVITE_TEXT + invite_url;
  592.  
  593.         // opts may have values for the updateFrequency of the main feeds.
  594.         var self = this;
  595.         console.debug('setAccountOptions');
  596.         $.each(this.feeds, function (name, feed) {
  597.             if (feed.name in self.account_opts_times) {
  598.                 var feedopts = self.account_opts_times[feed.name];
  599.                 var optName = feedopts[0];
  600.                 var defaultUpdateMins = feedopts[1];
  601.                 var minutes = optName in opts ? opts[optName] : defaultUpdateMins;
  602.  
  603.                 console.debug('feed: ' + feed.name + ' ' + minutes);
  604.                 feed.source.setUpdateFrequency(1000 * 60 * minutes);
  605.             }
  606.         });
  607.  
  608.         this.searchUpdateFrequency = 1000 * 60 * get(opts, 'search_updatefreq', 2);
  609.  
  610.         settings.autoscroll_when_at_bottom = get(opts, 'autoscroll_when_at_bottom', true);
  611.  
  612.         this.offlineMode = opts.offlineMode;
  613.         if (this.offlineMode)
  614.             console.warn('twitter is in offline mode');
  615.     },
  616.  
  617.     initializeFeeds: function(tx, userFeeds, opts, success, error) {
  618.         var self = this;
  619.  
  620.         if (!this.selfUser && !this.selfScreenNameLower) {
  621.             // if we haven't looked up our own id yet, then do so first
  622.             this.verifyCredentials(function() { self.initializeFeeds(undefined, userFeeds, opts, success, error); });
  623.             return;
  624.         }
  625.  
  626.         var tweets = this.tweets = {};
  627.  
  628.         // the base set of all feeds
  629.         this.customFeeds = [];
  630.         var feeds = this.feeds = {
  631.             favorites: new TwitterFavoritesFeedModel(tweets),
  632.             history: new TwitterHistoryFeedModel(tweets, {screen_name: this.selfScreenNameLower})
  633.         };
  634.  
  635.         self.sources = $.map(objectValues(feeds), function(f) { return f.makeSource(self) || null; });
  636.         console.warn('created ' + self.sources.length + ' self.sources');
  637.  
  638.         if (userFeeds.length) {
  639.             // Construct feeds here. Take care to construct the main timeline
  640.             // view first, since it needs to be around when we construct the other
  641.             // feeds, which may affect it via filtering and merging.
  642.             var userFeedsRearranged = Array.apply(null, userFeeds);
  643.             userFeedsRearranged.sort(function(a, b) {
  644.                 // timeline goes first
  645.                 return -cmp(a.type==='timeline', b.type==='timeline');
  646.             });
  647.             $.each(userFeedsRearranged, function (i, feedOpts) { self.addFeed(feedOpts, false); });
  648.  
  649.             // Rearrange the feeds back into their original user order.
  650.             this.setFeeds(userFeeds);
  651.         }
  652.  
  653.         this.setAccountOptions(opts);
  654.  
  655.         var successCount = 0;
  656.         function _success(tablename, tweets) {
  657.             console.log('loaded ' + tweets.length + ' cached tweets');
  658.             assert(feeds);
  659.             self.newTweets(tweets);
  660.  
  661.             var toDeleteIds = [];
  662.  
  663.             assert(tweets);
  664.  
  665.             // discard any tweets that we pulled out of the cache, but
  666.             // not added to any timelines
  667.             $.each(tweets, function (i, tweet) {
  668.                 if (!tweet.feeds || !objectKeys(tweet.feeds).length)
  669.                     toDeleteIds.push(tweet.id);
  670.             });
  671.  
  672.             console.debug('discarding ' + toDeleteIds.length + ' ' + tablename);
  673.             self.discard(tablename, toDeleteIds);
  674.             if (++successCount === 2 && success)
  675.                 success();
  676.         }
  677.  
  678.         function _error(err) {
  679.             console.error('error: ' + err.message);
  680.             if (error) error(err);
  681.         }
  682.  
  683.         self.loadAllCached({tx: tx,
  684.                             success: _success,
  685.                             error: _error});
  686.     },
  687.  
  688.     setFeeds: function(newFeeds) {
  689.         // rearrange feeds
  690.         var feeds = this.feeds;
  691.         this.customFeeds = $.map(newFeeds, function(f) {
  692.             return feeds[f.name];
  693.         });
  694.         this.notifyFeeds();
  695.     },
  696.  
  697.     changeView: function (feed) {
  698.         console.warn('changeView(' + JSON.stringify(feed) + ')');
  699.         console.warn('  typeof feed is ' + (typeof feed));
  700.  
  701.         if (typeof feed !== 'string') {
  702.             // can be a new feed description
  703.             console.log('got a feed description, calling addFeed');
  704.             feed = this.addFeed(feed, true);
  705.             return;
  706.         }
  707.  
  708.         var self = this;
  709.  
  710.         var timeline = this.timeline;
  711.         timeline.pauseMarkAsRead(true);
  712.         timeline.viewChanged(feed);
  713.  
  714.         timeline.pauseMarkAsRead(false, function() {
  715.             if (self.feeds[feed].scrollToBottom)
  716.                 // history and favorites start scrolled to the bottom
  717.                 timeline.scrollToBottom();
  718.             else
  719.                 timeline.scrollToNew();
  720.         });
  721.  
  722.         console.warn('changeView: ' + feed);
  723.  
  724.         // switching away from a non-saved search feed deletes it.
  725.         var toDelete = [];
  726.         $.each(this.feeds, function (i, f) {
  727.             if (f.query && !f.save && f.name !== feed)
  728.                 toDelete.push(f.name);
  729.         });
  730.  
  731.         if (toDelete.length)
  732.             $.each(toDelete, function (i, name) { self.deleteFeed({feedName: name}); });
  733.  
  734.         this.notifyClients('viewChanged', feed);
  735.     },
  736.  
  737.     timelineClosing: function() {
  738.         console.log('timelineClosing');
  739.         var self = this;
  740.         var feed = this.timeline.feed;
  741.         if (feed.query && !feed.save) {
  742.             console.log('yes');
  743.             setTimeout(function() { self.deleteFeed({feedName: feed.name}); }, 50);
  744.         }
  745.     },
  746.  
  747.     setupSql: function (success, error) {
  748.         function _success(tx) {
  749.             executeSql(tx,
  750.                 'create table if not exists info_keyed (' +
  751.                 'key text primary key, ' +
  752.                 'value text)', [],
  753.                 success, error);
  754.         }
  755.  
  756.         var self = this;
  757.         var makeTables = function (tx, sqls) {
  758.             if (sqls.length === 0)
  759.                 _success(tx);
  760.             else
  761.                 sqls[0].ensureTableExists(tx, function (tx, result) {
  762.                     makeTables(tx, sqls.slice(1));
  763.                 }, error);
  764.         };
  765.  
  766.         function didCheckForExistingTweets(tx) {
  767.             makeTables(tx, objectValues(self.sql));
  768.         }
  769.  
  770.         // Assume that if the "Tweets" table does not exist, this is a new account. We'll mark
  771.         // tweets from the first update as already read.
  772.         window.db.transaction(function (tx) {
  773.             executeSql(tx, "select * from Tweets limit 1", [],
  774.                 didCheckForExistingTweets,
  775.                 function (tx, err) { self.firstUpdate = true; didCheckForExistingTweets(tx); }
  776.             );
  777.         });
  778.     },
  779.  
  780.     isSelfTweet: function(t) {
  781.         var user = this.users[t.user];
  782.         if (user) {
  783.             if (this.selfUser)
  784.                 return user.id === this.selfUser.id;
  785.             else
  786.                 return user.screen_name.toLowerCase() === this.selfScreenNameLower;
  787.         }
  788.     },
  789.  
  790.     isMention: function(t) {
  791.         // consider tweets with your @username not at the beginning
  792.         // still a mention
  793.         return (
  794.             t.text.toLowerCase().search('@' + this.selfScreenNameLower) !== -1)
  795.             ? true : false;
  796.     },
  797.  
  798.     isDirect: function(t) {
  799.         return t.recipient_id ? true : false;
  800.     },
  801.  
  802.     verifyCredentials: function(done) {
  803.         var self = this;
  804.  
  805.         var realDone = done;
  806.  
  807.         loadServerTime(function() {
  808.  
  809.             function onJSON(json) {
  810.                 self.selfUser = self.users[json.id] = self.sql.users.fromJSON(json);
  811.                 self.selfScreenNameLower = self.selfUser.screen_name.toLowerCase();
  812.                 if (done) done();
  813.             }
  814.  
  815.             function cacheCredentials(data) {
  816.                 var json = JSON.stringify(data);
  817.                 console.log('caching credentials');
  818.                 window.db.transaction(function(tx) {
  819.                     executeSql(tx, "insert or replace into info_keyed values (?, ?)",
  820.                                   [self.username, json]);
  821.                 }, errorFunc('error caching credentials'));
  822.             }
  823.  
  824.             function doVerifyRequest() {
  825.                 var url = self.apiRoot + 'account/verify_credentials.json';
  826.                 self.urlRequest(url,
  827.                     function (data) {
  828.                         console.log('verify_credentials call returned successfully');
  829.                         cacheCredentials(data);
  830.                         onJSON(data);
  831.                     },
  832.                     function (error) {
  833.                         console.error('could not verify credentials: ' + error.message);
  834.                         self.selfScreenNameLower = self.username.toLowerCase();
  835.                         if (done) done();
  836.                     }
  837.                 );
  838.             }
  839.  
  840.             console.log('checking for credentials in database.');
  841.             window.db.transaction(function(tx) {
  842.                 function success(tx, result) {
  843.                     if (result.rows.length) {
  844.                         onJSON(JSON.parse(result.rows.item(0).value));
  845.                     } else {
  846.                         doVerifyRequest();
  847.                     }
  848.                 }
  849.                 executeSql(tx, "select value from info where key == ?",
  850.                     [self.username], success, doVerifyRequest);
  851.             });
  852.         });
  853.     },
  854.  
  855.     getTweetType: function(t) {
  856.         var tweet_type = 'timeline';
  857.  
  858.         if (this.isSelfTweet(t))
  859.             tweet_type = 'sent';
  860.         else if (t.mention)
  861.             tweet_type = 'mention';
  862.         else if (t.search)
  863.             tweet_type = 'search';
  864.         else if (t.sender_id)
  865.             tweet_type = 'direct';
  866.         return tweet_type;
  867.     },
  868.  
  869.     getFollowingUsers: function() {
  870.         var self = this,
  871.             url = this.apiRoot + 'statuses/friends.json';
  872.         this.pagedUrlRequest(url, {
  873.             itemsKey: 'users',
  874.             success: function (users) {
  875.                 if (!users.length)
  876.                     console.warn('no users returned in success callback for getFollowingUsers');
  877.                 else
  878.                     // save users out to database.
  879.                     window.db.transaction(function (tx) {
  880.                         $.each(users, function(i, user) {
  881.                             self.sql.users.insertOrReplace(tx, user);
  882.                         });
  883.                     }, function (err) {
  884.                         console.error('error caching users following: ' + err);
  885.                     });
  886.             },
  887.             error: function (xhr, error) {
  888.                 console.error('error getting users we are following: ' + error);
  889.             }
  890.         });
  891.     },
  892.  
  893.     getFollowing: function(opts) {
  894.         var opts = opts || {};
  895.  
  896.         var self = this;
  897.         if (!this.didGetFollowing) {
  898.             this.didGetFollowing = true;
  899.             var followingUrl = this.apiRoot + 'friends/ids.json';
  900.             function success(data) {
  901.                 self.notifyClients('didReceiveFollowing', data);
  902.                 var following = JSON.parse('['+data+']');
  903.                 self.maybeGetAllUsers(following);
  904.                 self.following_ids = set(following);
  905.                 if (opts.success)
  906.                     opts.success();
  907.             }
  908.             this.urlRequest(followingUrl, success, errorFunc('retreiving IDs the user is following'),
  909.                             {dataType: undefined});
  910.         }
  911.     },
  912.  
  913.     maybeGetAllUsers: function(following) {
  914.         // if there are any IDs in following that we don't have users for, go
  915.         // get them from the network.
  916.         var self = this, missing = false;
  917.         $.each(following, function (i, id) {
  918.             if (!(id in self.users)) {
  919.                 self.getFollowingUsers();
  920.                 return false;
  921.             }
  922.         });
  923.     },
  924.  
  925.     getTrends: function() {
  926.         var self = this;
  927.         function success(data) { self.notifyClients('didReceiveTrends', data); }
  928.         var url = this.searchApiRoot + 'trends/current.json';
  929.         this.urlRequest(url, success, errorFunc('error retrieving trends'));
  930.     },
  931.  
  932.     onRealTimeTweet: function(tweetjson) {
  933.         var self = this;
  934.  
  935.         if ('delete' in tweetjson)
  936.             return this.onRealTimeDeleteTweet(tweetjson);
  937.  
  938.         // realtime tweet stream also includes @replys to people in the following
  939.         // list, so filter out any tweets from users not in our following_ids set
  940.         if (this.showAllRealtime ||
  941.             (this.following_ids && tweetjson.user.id in this.following_ids)) {
  942.  
  943.             // filter out replies from people we're following to people we are
  944.             // NOT following, to match the website. (settting?)
  945.             var reply_to = tweetjson.in_reply_to_user_id;
  946.             if (!reply_to || reply_to in this.following_ids) {
  947.                 var tweet = this.makeTweetFromNetwork(tweetjson);
  948.                 assert(tweet !== undefined);
  949.                 self.appendTweet(tweet);
  950.                 if (!this.isSelfTweet(tweet))
  951.                     showPopupsForTweets(this, [tweet]);
  952.                 this.cacheTweets([tweet]);
  953.                 this.doNotifyUnread();
  954.             }
  955.         }
  956.     },
  957.  
  958.     onRealTimeDeleteTweet: function(tweetjson) {
  959.         console.log('TODO: streaming API sent delete notice for status ' + tweetjson.status.id);
  960.     },
  961.  
  962.     getUsers: function(opts, justFollowing) {
  963.         if (justFollowing === undefined)
  964.             justFollowing = true;
  965.  
  966.         // if justFollowing is true (the default), only return users with ids
  967.         // in this.following_ids
  968.         var usersMap;
  969.         if (justFollowing && this.following_ids) {
  970.             usersMap = {};
  971.             var followingIds = this.following_ids;
  972.             $.each(this.users, function (id, user) {
  973.                 if (id in followingIds) usersMap[id] = user;
  974.             });
  975.         } else
  976.             usersMap = this.users;
  977.  
  978.         if (opts.success)
  979.             opts.success(usersMap);
  980.     },
  981.  
  982.     addClient: function(client) {
  983.         this.clients.push(client);
  984.     },
  985.  
  986.     changeState: function(state) {
  987.         console.warn('changeState: ' + state);
  988.  
  989.         if (state === 'oautherror' && this.state !== 'oautherror') {
  990.             this.state = state;
  991.             this.notifyClients('stateChanged', 'oautherror');
  992.             this.stopTimers('oautherror');
  993.         } else if (state === 'autherror') {
  994.             this.state = state;
  995.             this.notifyClients('stateChanged', 'autherror');
  996.             this.stopTimers('autherror');
  997.         } else if (state == 'connfail') {
  998.             if (this.state !== 'autherror') { // do not go from autherror to connfail
  999.                 this.state = state;
  1000.                 this.notifyClients('stateChanged', 'connfail');
  1001.                 this.stopTimers('connection fail');
  1002.             }
  1003.         } else if (state == 'online') {
  1004.             if (this.state === undefined) {
  1005.                 this.state = 'online';
  1006.                 this.notifyClients('stateChanged', 'online');
  1007.             }
  1008.         } else {
  1009.             console.error('changeState got unknown state: ' + state);
  1010.         }
  1011.     },
  1012.  
  1013.     notifyClients: function(funcName/*, *args */) {
  1014.         var args = Array.apply(null, arguments);
  1015.         args.shift();
  1016.  
  1017.         $.each(this.clients, function(i, client) {
  1018.             client[funcName].apply(client, args);
  1019.         });
  1020.     },
  1021.  
  1022.     notifyFeeds: function(name) {
  1023.         var feedsJSON = $.map(this.customFeeds, function (feed) {
  1024.             return feed.serialize ? feed.serialize() : null;
  1025.         });
  1026.         this.notifyClients(name || 'feedsUpdated', feedsJSON);
  1027.     },
  1028.  
  1029.     getTweet: function(id, success, error) {
  1030.         var self = this;
  1031.  
  1032.         // first, try loading the tweet from the cache
  1033.         var cacheSuccess = function(tx, tweets) {
  1034.             if (tweets.length !== 1) {
  1035.                 console.log('cacheSuccess expected one tweet, got ' + tweets.length);
  1036.                 return loadFromNetwork();
  1037.             }
  1038.             success(tweets[0]);
  1039.         };
  1040.  
  1041.         var cacheError = function(err) {
  1042.             console.error('could not retreive tweet ' + id + ' from the database: ' + err.message);
  1043.             loadFromNetwork();
  1044.         };
  1045.  
  1046.         // if that fails, grab it from the network
  1047.         var loadFromNetwork = function() {
  1048.             function urlSuccess(data) {
  1049.                 var tweet = self.makeTweetFromNetwork(data);
  1050.                 self.cacheTweets([tweet], function() { success(tweet); });
  1051.             }
  1052.  
  1053.             var url = self.apiRoot + 'statuses/show/' + id + '.json';
  1054.             self.urlRequest(url, urlSuccess, error);
  1055.         };
  1056.  
  1057.         this.loadCachedTweets({id: id, limit: 1, success: cacheSuccess, error: cacheError});
  1058.     },
  1059.  
  1060.     /**
  1061.      * Handles X-RateLimit- HTTP response headers indicating API request
  1062.      * limits.
  1063.      */
  1064.     handleRateLimits: function(xhr, textStatus) {
  1065.         var r = {limit:     xhr.getResponseHeader('X-RateLimit-Limit'),
  1066.                  remaining: xhr.getResponseHeader('X-RateLimit-Remaining'),
  1067.                  reset:     xhr.getResponseHeader('X-RateLimit-Reset')};
  1068.  
  1069.         var dateStr = xhr.getResponseHeader('Date');
  1070.         if (dateStr) {
  1071.             var date = new Date(dateStr);
  1072.             if (date) receivedCorrectTimestamp(date, true);
  1073.         }
  1074.  
  1075.  
  1076.         if (r.limit && r.remaining && r.reset) {
  1077.             this.rateLimit = r;
  1078.             if (parseInt(r.remaining, 10) < 20)
  1079.                 console.log('remaining API requests: ' + r.remaining);
  1080.         }
  1081.     },
  1082.  
  1083.     /**
  1084.      * Sends a tweet or direct.
  1085.      *   replyTo   can be an id that the tweet is in reply to
  1086.      *   success   is called with the tweet object
  1087.      *   error     is called with an exception object
  1088.      */
  1089.     tweet: function(text, replyTo, success, error) {
  1090.         // make "d screen_name message" a direct message
  1091.         var direct = text.match(/^d\s+(\S+)\s+(.+)/);
  1092.         if (direct) {
  1093.             var screen_name = direct[1];
  1094.             if (screen_name.charAt(screen_name.length-1) === ':')
  1095.                 screen_name = screen_name.slice(0, screen_name.length-1);
  1096.             text = direct[2];
  1097.             return this.direct(screen_name, text, success, error);
  1098.         }
  1099.  
  1100.         var self = this;
  1101.         var opts = {status: text,
  1102.                     source: 'digsby'};
  1103.         if (replyTo)
  1104.             opts.in_reply_to_status_id = replyTo;
  1105.  
  1106.         var url = this.apiRoot + 'statuses/update.json';
  1107.  
  1108.         function _success(data) {
  1109.             self.notifyClients('statusUpdated');
  1110.             var tweet = self.makeTweetFromNetwork(data);
  1111.             self.appendTweet(tweet, {ignoreIds: true});
  1112.             self.cacheTweets([tweet], function() {
  1113.                 var t = notifyTweet(self, tweet);
  1114.                 self.notifyClients('selfTweet', t);
  1115.                 if (success) success(t);
  1116.             });
  1117.         }
  1118.  
  1119.         this.urlRequest(url, _success, error, {type: 'POST', data: opts});
  1120.     },
  1121.  
  1122.     direct: function(screen_name, text, success, error) {
  1123.         var self = this,
  1124.             opts = {text: text, screen_name: screen_name},
  1125.             url = this.apiRoot + 'direct_messages/new.json';
  1126.  
  1127.         var _success = function(data) {
  1128.             self.notifyClients('directSent');
  1129.             var direct = self.makeDirectFromNetwork(data);
  1130.             self.appendTweet(direct);
  1131.             self.cacheDirects([direct], function() {
  1132.                 if (success) success(notifyTweet(self, direct));
  1133.             });
  1134.         };
  1135.  
  1136.         this.urlRequest(url, _success, extractJSONError('Error sending direct message', error), {type: 'POST', data: opts});
  1137.     },
  1138.  
  1139.     deleteTweet: function(opts) {
  1140.         return this.deleteItem('tweets', 'statuses/destroy/', opts);
  1141.     },
  1142.  
  1143.     deleteDirect: function(opts) {
  1144.         return this.deleteItem('directs', 'direct_messages/destroy/', opts);
  1145.     },
  1146.  
  1147.     deleteItem: function(sql, urlPart, opts) {
  1148.         var self = this;
  1149.  
  1150.         function success(tweet) {
  1151.             useStringIdentifiers(tweet, ['id', 'in_reply_to_status_id']);
  1152.             console.log('successfully deleted ' + sql + ' ' + tweet.id);
  1153.             self.discard(sql, [tweet.id]); // remove from sql cache
  1154.             delete self.tweets[tweet.id];  // remove from memory cache
  1155.             // remove from any feeds.
  1156.             $.each(self.feeds, function (i, feed) { feed.removeItem(tweet); });
  1157.  
  1158.             if (opts.success) {
  1159.                 // scroll to bottom if necessary.
  1160.                 if (self.timeline)
  1161.                     self.timeline.scrollToBottomIfAtBottom(function() { opts.success(tweet); });
  1162.                 else
  1163.                     opts.success(tweet);
  1164.             }
  1165.         }
  1166.  
  1167.         return this.urlRequest(this.apiRoot + urlPart + opts.id + '.json',
  1168.                                success,
  1169.                                opts.error,
  1170.                                {type: 'POST'});
  1171.     },
  1172.  
  1173.     follow: function(opts) {
  1174.         console.warn('follow: ' + JSON.stringify(opts));
  1175.  
  1176.         if (opts.screen_name) {
  1177.             function success(data) {
  1178.                 console.warn('follow success:');
  1179.                 console.warn(JSON.stringify(data));
  1180.                 if (opts.success)
  1181.                     opts.success();
  1182.             }
  1183.  
  1184.             function error(err) {
  1185.                 console.error('error following' + opts.screen_name);
  1186.                 printException(err);
  1187.                 if (opts.error) opts.error(err);
  1188.             }
  1189.  
  1190.             this.urlRequest(this.apiRoot + 'friendships/create.json',
  1191.                             success, opts.error || errorFunc('error following'),
  1192.                             {type: 'POST', data: {screen_name: opts.screen_name}});
  1193.         }
  1194.     },
  1195.  
  1196.     appendTweet: function(tweet, opts) {
  1197.         if (this.timeline) {
  1198.             var self = this;
  1199.             this.timeline.scrollToBottomIfAtBottom(function() {
  1200.                 opts = opts || {};
  1201.                 opts.scroll = false;
  1202.                 self.newTweets([tweet], undefined, true, opts);
  1203.             });
  1204.         }
  1205.     },
  1206.  
  1207.     newTweets: function (tweets, source, updateNow, opts) {
  1208.         if (opts === undefined) opts = {};
  1209.         opts.account = this;
  1210.         callEach(this.feeds, 'addSorted', tweets, source, updateNow, opts);
  1211.         var timeline = this.feeds.timeline;
  1212.         if (this.lastNotifiedMaxId === undefined ||
  1213.             this.lastNotifiedMaxId !== timeline.source.maxId) {
  1214.             var recentTweets = timeline.items.slice(-200);
  1215.             this.notifyClients('recentTimeline', recentTweets);
  1216.         }
  1217.     },
  1218.  
  1219.     /**
  1220.      * twitter API has paged requests using a "cursor" parameter
  1221.      */
  1222.     pagedUrlRequest: function(url, opts) {
  1223.         if (opts.itemsKey === undefined)
  1224.             throw 'pagedUrlRequest: must provide "itemsKey" in opts'
  1225.  
  1226.         var self = this,
  1227.             nextCursor = -1;
  1228.             allData = [];
  1229.  
  1230.         function _success(data) {
  1231.             // append data
  1232.             arrayExtend(allData, data[opts.itemsKey]);
  1233.             nextCursor = data.next_cursor;
  1234.  
  1235.             if (nextCursor === 0) // next_cursor 0 means we hit the end
  1236.                 return opts.success(allData);
  1237.             else
  1238.                 nextRequest();
  1239.         }
  1240.  
  1241.         function nextRequest() {
  1242.             opts.data = {cursor: nextCursor};
  1243.             self.urlRequest(url, _success, opts.error, opts);
  1244.         }
  1245.  
  1246.         nextRequest();
  1247.     },
  1248.  
  1249.     /* currently unused */
  1250.     getFollowedBy: function(_success) {
  1251.         var self = this;
  1252.         var url = this.apiRoot + 'followers/ids.json';
  1253.         this.pagedUrlRequest(url, {
  1254.             itemsKey: 'ids',
  1255.             success: function (ids) {
  1256.                 self.followers = set(ids);
  1257.                 if (_success)
  1258.                     _success(ids);
  1259.             },
  1260.             error: errorFunc('retreiving followers')
  1261.         });
  1262.     },
  1263.  
  1264.     urlRequest: function(url, success, error, opts) {
  1265.         var self = this;
  1266.         opts = opts || {};
  1267.  
  1268.         function _error(xhr, textStatus, errorThrown) {
  1269.             if (xhr.status === 401) {
  1270.                 console.error('authentication error');
  1271.  
  1272.                 if (!self.didUpdate) {
  1273.                     if (self.oauth)
  1274.                         self.changeState('oautherror');
  1275.                     else
  1276.                         // ignore auth errors after already connected
  1277.                         self.changeState('autherror');
  1278.                 }
  1279.             }
  1280.  
  1281.             if (error)
  1282.                 error(xhr, textStatus, errorThrown);
  1283.         }
  1284.  
  1285.         var httpType = opts.type || 'GET';
  1286.         console.log(httpType + ' ' + url);
  1287.  
  1288.         var basicAuthUsername, basicAuthPassword;
  1289.         var authHeader;
  1290.  
  1291.         if (url.substr(0, this.searchApiRoot.length) !== this.searchApiRoot) {
  1292.             if (this.oauth) {
  1293.                 if (!opts.data) opts.data = {};
  1294.                 authHeader = this.getOAuthHeader({url: url, method: httpType, data: opts.data});
  1295.             } else {
  1296.                 basicAuthUsername = encodeURIComponent(this.username);
  1297.                 basicAuthPassword = encodeURIComponent(this.password);
  1298.             }
  1299.         }
  1300.  
  1301.         return $.ajax({
  1302.             url: url,
  1303.             success: success,
  1304.             error: _error,
  1305.             type: httpType,
  1306.             data: opts.data,
  1307.  
  1308.             dataType: opts.dataType || 'json',
  1309.             dataFilter: function (data, type) {
  1310.                 var obj;
  1311.                 if (type.toLowerCase() === 'json') {
  1312.                     try {
  1313.                         obj = JSON.parse(data);
  1314.                     } catch (err) {
  1315.                         // show what the bad data was, if it wasn't JSON
  1316.                         console.error('ERROR parsing JSON response, content was:');
  1317.                         console.error(data);
  1318.                         throw err;
  1319.                     }
  1320.  
  1321.                     return obj;
  1322.                 } else
  1323.                     return data;
  1324.             },
  1325.  
  1326.             username: basicAuthUsername,
  1327.             password: basicAuthPassword,
  1328.  
  1329.             complete: this.handleRateLimits,
  1330.             beforeSend: function(xhr) {
  1331.                 xhr.setRequestHeader('User-Agent', 'Digsby');
  1332.                 if (authHeader)
  1333.                     xhr.setRequestHeader('Authorization', authHeader);
  1334.             }
  1335.         });
  1336.     },
  1337.  
  1338.     getOAuthHeader: function(opts) {
  1339.         var paramList = [];
  1340.         for (var k in opts.data)
  1341.             paramList.push([k, opts.data[k]]);
  1342.  
  1343.         paramList.sort(function(a, b) {
  1344.             if (a[0] < b[0]) return -1;
  1345.             if (a[0] > b[0]) return 1;
  1346.             return 0;
  1347.         });
  1348.  
  1349.         var message = {
  1350.             action: opts.url,
  1351.             method: opts.method,
  1352.             parameters: paramList
  1353.         };
  1354.  
  1355.         var accessor = {
  1356.             consumerKey: this.oauth.consumerKey,
  1357.             consumerSecret: this.oauth.consumerSecret,
  1358.  
  1359.             token: this.oauth.tokenKey,
  1360.             tokenSecret: this.oauth.tokenSecret
  1361.         };
  1362.  
  1363.         OAuth.completeRequest(message, accessor);
  1364.         return OAuth.getAuthorizationHeader(undefined, message.parameters)
  1365.     },
  1366.  
  1367.     favorite: function(tweet, onSuccess, onError) {
  1368.         var self = this;
  1369.         var url, favoriting;
  1370.         if (tweet.favorited) {
  1371.             url = this.apiRoot + 'favorites/destroy/' + tweet.id + '.json';
  1372.             favoriting = false;
  1373.         } else {
  1374.             url = this.apiRoot + 'favorites/create/' + tweet.id + '.json';
  1375.             favoriting = true;
  1376.         }
  1377.  
  1378.         // TODO: this is the same as getTweet
  1379.         function urlSuccess(data) {
  1380.             var tweet = self.makeTweetFromNetwork(data);
  1381.             tweet.favorited = favoriting;
  1382.             self.cacheTweets([tweet], function() { if (onSuccess) {onSuccess(tweet);} });
  1383.         }
  1384.  
  1385.         this.urlRequest(url, urlSuccess, onError, {type: 'POST'});
  1386.     },
  1387.  
  1388.     cacheFavorited: function(tweetIds, favorited) {
  1389.         var ids = '(' + joinSingleQuotedStringArray(tweetIds, ', ') + ')';
  1390.         var sql = 'update or ignore tweets set favorited = ' + (favorited ? '1' : '0') + ' where id in ' + ids;
  1391.         window.db.transaction(function(tx) {
  1392.             executeSql(tx, sql, [], null, errorFunc('error unfavoriting items'));
  1393.         });
  1394.     },
  1395.  
  1396.     makeTweetFromNetwork: function(item, markRead) {
  1397.         useStringIdentifiers(item, ['id', 'in_reply_to_status_id']);
  1398.  
  1399.         if (item.id in this.tweets)
  1400.             return this.tweets[item.id];
  1401.  
  1402.         item = transformRetweet(item);
  1403.  
  1404.         var tweet = this.makeTweet(item);
  1405.         var user = tweet.user;
  1406.         // tweets from network have full User JSON
  1407.         this.users[user.id] = user;
  1408.         // but we'll just store ID in memory and on disk
  1409.         tweet.user = user.id;
  1410.         // categorize mentions as such if they contain @username
  1411.         tweet.mention = this.isMention(tweet);
  1412.  
  1413.         if (this.isSelfTweet(tweet)) {
  1414.             this.maybeNotifySelfTweet(tweet);
  1415.             tweet.read = 1;
  1416.         } else if (markRead) {
  1417.             tweet.read = 1;
  1418.         } else {
  1419.             tweet.read = 0;
  1420.         }
  1421.  
  1422.         return tweet;
  1423.     },
  1424.  
  1425.     makeDirect: function(item, override) {
  1426.         var direct = this.sql.directs.fromJSON(item, override);
  1427.         this.tweets[direct.id] = direct;
  1428.         direct.toString = directToString;
  1429.         direct.created_at_ms = new Date(direct.created_at).getTime();
  1430.         direct.user = direct.sender_id;
  1431.         return direct;
  1432.     },
  1433.  
  1434.     makeDirectFromNetwork: function(item, markRead) {
  1435.         useStringIdentifiers(item, ['id']);
  1436.         if (item.id in this.tweets)
  1437.             return this.tweets[item.id];
  1438.         var direct = this.makeDirect(item);
  1439.  
  1440.         this.users[direct.user] = item.sender;
  1441.         direct.read = (markRead || this.isSelfTweet(direct)) ? 1 : 0;
  1442.  
  1443.         return direct;
  1444.     },
  1445.  
  1446.     makeTweet: function(item, override) {
  1447.         var tweet = this.sql.tweets.fromJSON(item, override);
  1448.         this.tweetIn(tweet);
  1449.         return tweet;
  1450.     },
  1451.  
  1452.     tweetIn: function(tweet) {
  1453.         this.tweets[tweet.id] = tweet;
  1454.         tweet.toString = tweetToString;
  1455.         tweet.created_at_ms = new Date(tweet.created_at).getTime();
  1456.     },
  1457.  
  1458.     cacheTweets: function (tweets, ondone) {
  1459.         return this.cacheItems(tweets, ondone, this.sql.tweets);
  1460.     },
  1461.  
  1462.     cacheDirects: function (directs, ondone) {
  1463.         return this.cacheItems(directs, ondone, this.sql.directs);
  1464.     },
  1465.  
  1466.     cacheItems: function (tweets, ondone, sql) {
  1467.         if (tweets.length === 0) {
  1468.             if (ondone) guard(ondone);
  1469.             return;
  1470.         }
  1471.  
  1472.         console.log("cacheItems() is saving " + tweets.length + " items to " + sql);
  1473.  
  1474.         var self = this;
  1475.         window.db.transaction(function(tx) {
  1476.             // insert tweets
  1477.             $.each(tweets, function (i, tweet) {
  1478.  
  1479.                 sql.insertOrReplace(tx, tweet);
  1480.             });
  1481.  
  1482.             // update users
  1483.             $.each(tweets, function (i, tweet) {
  1484.                 if (tweet.user !== null)
  1485.                     self.sql.users.insertOrReplace(tx, self.users[tweet.user]);
  1486.             });
  1487.  
  1488.         }, function (error) {
  1489.             console.error('Failed to cache tweets to database: ' + error.message);
  1490.             if (ondone) ondone();
  1491.         }, function () {
  1492.             if (ondone) ondone();
  1493.         });
  1494.     },
  1495.  
  1496.     discard: function(tablename, ids) {
  1497.         if (ids.length === 0) return;
  1498.         ids = '(' + joinSingleQuotedStringArray(ids, ', ') + ')';
  1499.         var deleteStatement = 'delete from ' + tablename + ' where id in ' + ids;
  1500.         window.db.transaction(function (tx) {
  1501.             executeSql(tx, deleteStatement, [], null, errorFunc('discarding ' + tablename));
  1502.         });
  1503.     },
  1504.  
  1505.     markAllAsRead: function() {
  1506.         this.markTweetsAsRead(this.tweets);
  1507.     },
  1508.  
  1509.     markFeedAsRead: function(name) {
  1510.         if (name in this.feeds)
  1511.             this.markTweetsAsRead(this.feeds[name].items);
  1512.     },
  1513.  
  1514.     toggleAddsToCount: function(name) {
  1515.         var feed = this.feeds[name];
  1516.         if (feed) {
  1517.             feed.noCount = !feed.noCount;
  1518.             this.notifyFeeds();
  1519.             this.doNotifyUnread();
  1520.         }
  1521.     },
  1522.  
  1523.     markTweetsAsRead: function(tweets) {
  1524.         var self = this;
  1525.         $.each(tweets, function (i, t) { self.markAsRead(t); });
  1526.     },
  1527.  
  1528.     markAsRead: function(item) {
  1529.         var self = this;
  1530.  
  1531.         if (!item || item.read) return;
  1532.         var id = item.id;
  1533.  
  1534.         //console.log('markAsRead: ' + item.text);
  1535.         item.read = 1;
  1536.         if (item.feeds)
  1537.             $.each(item.feeds, function(i, feed) { feed.markedAsRead(item); });
  1538.  
  1539.         self.doNotifyUnread();
  1540.  
  1541.         if (this.markAsReadLater === undefined)
  1542.             this.markAsReadLater = {};
  1543.  
  1544.         this.markAsReadLater[id] = true;
  1545.  
  1546.         if (this.markAsReadTimer === undefined) {
  1547.             var unreadTimerCb = function () {
  1548.                 self.recordReadTweets();
  1549.             };
  1550.             this.markAsReadTimer = this.ownerWindow.setTimeout(unreadTimerCb, 1000);
  1551.         }
  1552.     },
  1553.  
  1554.     connectionFailed: function() {
  1555.         this.notifyClients('connectionFailed');
  1556.         if (!this.didUpdate)
  1557.             // if this is the first login attempt, just stop timers and die
  1558.             this.stopTimers('connectionFailed');
  1559.     },
  1560.     /**
  1561.      * calls onRead on all clients with {name: unreadCount, ...} for
  1562.      * each feed.
  1563.      */
  1564.     doNotifyUnread: function(force) {
  1565.         var self = this;
  1566.         if (this.notifyUnreadTimer === undefined)
  1567.             this.notifyUnreadTimer = setTimeout(function() {
  1568.                 self._notifyUnread(self);
  1569.             }, 200);
  1570.     },
  1571.  
  1572.     _notifyUnread: function(self) {
  1573.         delete self.notifyUnreadTimer;
  1574.         var unread = {};
  1575.         var feeds = $.map(self.customFeeds, function (feed) {
  1576.             if (!feed.addsToUnreadCount())
  1577.                 // don't include tweets from unsaved searches
  1578.                 return null;
  1579.  
  1580.             $.each(feed.items, function (i, tweet) {
  1581.                 if (!tweet.read)
  1582.                     unread[tweet.id] = true;
  1583.             });
  1584.  
  1585.             return feed.serialize();
  1586.         });
  1587.  
  1588.         self.notifyClients('onUnread', {
  1589.             feeds: feeds,
  1590.             total: objectKeys(unread).length
  1591.         });
  1592.     },
  1593.  
  1594.     recordReadTweets: function() {
  1595.         delete this.markAsReadTimer;
  1596.  
  1597.         var ids = [];
  1598.         for (var id in this.markAsReadLater) {
  1599.             if (this.markAsReadLater[id])
  1600.                 ids.push(id);
  1601.         }
  1602.  
  1603.         this.markAsReadLater = [];
  1604.  
  1605.         if (ids.length) {
  1606.             var idsSql = joinSingleQuotedStringArray(ids, ', ');
  1607.             var sql1 = 'UPDATE Tweets SET read=1 WHERE Tweets.id IN (' + idsSql + ')';
  1608.             // TODO: hack. we're using two tables, don't do this
  1609.             var sql2 = 'UPDATE Directs SET read=1 WHERE Directs.id IN (' + idsSql + ')';
  1610.             window.db.transaction(function(tx) {
  1611.                 executeSql(tx, sql1);
  1612.                 executeSql(tx, sql2);
  1613.             }, function (error) {
  1614.                 console.error('failed to mark as read: ' + error.message);
  1615.                 console.error('sql was:');
  1616.                 console.error(sql1);
  1617.                 console.error(sql2);
  1618.             });
  1619.         }
  1620.     },
  1621.  
  1622.     loadAllCached: function(opts) {
  1623.         var self = this, allTweets = [];
  1624.  
  1625.             self.loadCachedUsers({tx: opts.tx, error: opts.error, success: function(tx) {
  1626.                 self.loadCachedTweets({tx: tx, error: opts.error, success: function(tx, tweets) {
  1627.                     opts.success('tweets', tweets);
  1628.  
  1629.                     // immediately notify the GUI of the newest self tweet we know about.
  1630.                     self.possibleSelfTweets(tweets);
  1631.  
  1632.                     self.loadCachedDirects({tx: tx, error: opts.error, success: function (tx, directs) {
  1633.                         opts.success('directs', directs);
  1634.                     }});
  1635.                 }});
  1636.             }})
  1637.     },
  1638.  
  1639.     possibleSelfTweets: function (tweets) {
  1640.         var selfTweet = this.findNewestSelfTweet(tweets);
  1641.         if (selfTweet) this.maybeNotifySelfTweet(selfTweet);
  1642.     },
  1643.  
  1644.     maybeNotifySelfTweet: function(tweet) {
  1645.         if (!this.newestSelfTweet || this.newestSelfTweet.created_at_ms < tweet.created_at_ms) {
  1646.             this.newestSelfTweet = notifyTweet(this, tweet);
  1647.             this.notifyClients('selfTweet', this.newestSelfTweet);
  1648.         }
  1649.     },
  1650.  
  1651.     findNewestSelfTweet: function (tweets) {
  1652.         var self = this, newest;
  1653.         $.each(tweets, function (i, tweet) {
  1654.             if (self.isSelfTweet(tweet) && tweet.created_at_ms &&
  1655.                 (!newest || tweet.created_at_ms > newest.created_at_ms))
  1656.                 newest = tweet;
  1657.         });
  1658.  
  1659.         if (newest)
  1660.             return newest;
  1661.     },
  1662.  
  1663.     addGroupsFromLists: function() {
  1664.         var self = this;
  1665.         this.getLists(function (groups) {
  1666.             $.each(groups, function (i, group) {
  1667.                 self.addGroup(group, false);
  1668.             });
  1669.         });
  1670.     },
  1671.  
  1672.     getLists: function(success) {
  1673.         var self = this,
  1674.             urlPrefix = this.apiRoot + this.selfScreenNameLower + '/';
  1675.  
  1676.         var getListsSuccess = success;
  1677.         var groups = [];
  1678.  
  1679.         self.pagedUrlRequest(urlPrefix + 'lists.json', {
  1680.             itemsKey: 'lists',
  1681.             error: errorFunc('could not get list ids'),
  1682.             success: function (lists) {
  1683.                 var i = 0;
  1684.  
  1685.                 function nextGroup() {
  1686.                     var list = lists[i++];
  1687.                     if (!list) return getListsSuccess(groups);
  1688.                     var group = {type: 'group', groupName: list.name,
  1689.                                  filter: false, popups: false, ids: []};
  1690.                     var url = urlPrefix + list.slug + '/members.json';
  1691.                     self.pagedUrlRequest(url, {
  1692.                         itemsKey: 'users',
  1693.                         error: errorFunc('error retrieving ' + url),
  1694.                         success: function (users) {
  1695.                             $.each(users, function (i, user) { group.ids.push(user.id); });
  1696.                             groups.push(group);
  1697.                             nextGroup();
  1698.                         }
  1699.                     });
  1700.                 }
  1701.  
  1702.                 nextGroup();
  1703.             }
  1704.         });
  1705.     },
  1706.  
  1707.     cachedDirectColumnNames: 'directs.id as direct_id, users.id as user_id, *',
  1708.  
  1709.     loadCachedDirects: function(opts) {
  1710.         var self = this;
  1711.  
  1712.         function success(tx, result) {
  1713.             var directs = [];
  1714.             for (var i = 0; i < result.rows.length; ++i) {
  1715.                 var row = result.rows.item(i);
  1716.                 var direct_id = row.direct_id, user_id = row.user_id;
  1717.                 if (user_id)
  1718.                     self.users[user_id] = self.sql.users.fromJSON(row, {id: user_id});
  1719.                 var direct = self.makeDirect(row, {id: direct_id});
  1720.                 directs.push(direct);
  1721.             }
  1722.             directs.reverse();
  1723.             if (opts.success) opts.success(tx, directs);
  1724.         }
  1725.  
  1726.         function error(tx, err) {
  1727.             if (opts.error) opts.error(err);
  1728.             else console.error('could not load cached directs: ' + err.message);
  1729.         }
  1730.  
  1731.         return this.loadCachedItems(opts, this.cachedDirectColumnNames, 'Directs', 'sender_id', success, error);
  1732.     },
  1733.  
  1734.     cachedTweetColumnNames: 'tweets.id as tweet_id, users.id as user_id, tweets.profile_image_url as tweet_image, users.profile_image_url as user_image, *',
  1735.  
  1736.     loadCachedTweets: function(opts) {
  1737.         var self = this;
  1738.  
  1739.         function success(tx, result) {
  1740.             var tweets = [];
  1741.             for (var i = 0; i < result.rows.length; ++i) {
  1742.                 var row = result.rows.item(i);
  1743.  
  1744.                 // avoid id name clash
  1745.                 var tweet_id = row.tweet_id,
  1746.                     user_id = row.user_id,
  1747.                     user_image = row.user_image,
  1748.                     tweet_image = row.tweet_image;
  1749.  
  1750.                 if (user_id)
  1751.                     self.users[user_id] = self.sql.users.fromJSON(row, {id: user_id, profile_image_url: user_image});
  1752.                 var tweet = self.makeTweet(row, {id: tweet_id, profile_image_url: tweet_image});
  1753.  
  1754.                 tweets.push(tweet);
  1755.             }
  1756.  
  1757.             tweets.reverse();
  1758.  
  1759.             if (opts.success)
  1760.                 opts.success(tx, tweets);
  1761.         }
  1762.  
  1763.         function error(tx, err) {
  1764.             if (opts.error) opts.error(err);
  1765.             else console.error('could not load cached tweets: ' + err.message);
  1766.         }
  1767.  
  1768.         return this.loadCachedItems(opts, this.cachedTweetColumnNames, 'Tweets', 'user', success, error);
  1769.     },
  1770.  
  1771.     loadCachedItems: function(opts, columnNames, tableName, userColumn, success, error) {
  1772.         /* id: adds 'WHERE tweet.id == <id>'
  1773.            limit: adds 'LIMIT <limit>' */
  1774.  
  1775.         var self = this;
  1776.  
  1777.         var args = [];
  1778.         var innerQuery = "SELECT " + columnNames + " FROM " + tableName +
  1779.                          " LEFT OUTER JOIN Users ON " + tableName + "." + userColumn + " = Users.id";
  1780.  
  1781.         // WHERE tweet_id
  1782.         if (opts.id) {
  1783.             innerQuery += ' WHERE tweet_id==?';
  1784.             args.push(opts.id);
  1785.         }
  1786.  
  1787.         innerQuery += " ORDER BY " + tableName + ".id DESC";
  1788.  
  1789.         // LIMIT clause
  1790.         if (opts.limit) {
  1791.             innerQuery += ' LIMIT ?';
  1792.             args.push(opts.limit);
  1793.         }
  1794.  
  1795.         var query = "SELECT * FROM (" + innerQuery + ")";
  1796.  
  1797.         console.log(query);
  1798.         useOrCreateTransaction(opts.tx, query, args, success, error);
  1799.     },
  1800.  
  1801.     getUsersToKeep: function() {
  1802.         var excludedUserIds = [];
  1803.         if (this.following_ids)
  1804.             arrayExtend(excludedUserIds, objectKeys(this.following_ids));
  1805.         var groupIds = this.getAllGroupIds();
  1806.         if (groupIds)
  1807.             arrayExtend(excludedUserIds, groupIds);
  1808.         if (this.selfUser)
  1809.             excludedUserIds.push(this.selfUser.id);
  1810.         return excludedUserIds;
  1811.     },
  1812.  
  1813.     discardOldUsers: function(opts) {
  1814.         // discards all users not referenced by tweets or directs
  1815.         opts = opts || {};
  1816.  
  1817.         var excludedUserIds = this.getUsersToKeep();
  1818.  
  1819.         // don't discard users we're following either
  1820.         var appendClause = '';
  1821.         if (excludedUserIds.length) {
  1822.             var usersFollowing = excludedUserIds.join(', ');
  1823.             appendClause = 'and id not in (' + usersFollowing + ')';
  1824.         }
  1825.  
  1826.         var query = 'delete from users where id not in (select sender_id from directs union select user from tweets union select recipient_id from directs)' + appendClause;
  1827.         useOrCreateTransaction(opts.tx, query, [], opts.success, opts.error);
  1828.     },
  1829.  
  1830.     loadCachedUsers: function(opts) {
  1831.         var self = this,
  1832.             query = 'SELECT * FROM Users';
  1833.  
  1834.         function success(tx, result) {
  1835.             var added = 0;
  1836.             for (var i = 0; i < result.rows.length; ++i) {
  1837.                 var row = result.rows.item(i);
  1838.                 if (!(row.id in self.users)) {
  1839.                     self.users[row.id] = self.sql.users.fromJSON(row);
  1840.                     added += 1;
  1841.                 }
  1842.             }
  1843.  
  1844.             console.log('loadCachedUsers loaded ' + added + ' users');
  1845.             if (opts.success)
  1846.                 opts.success(tx);
  1847.         }
  1848.  
  1849.         function error(err) {
  1850.             console.error('error loading cached users: ' + err);
  1851.             if (opts.error)
  1852.                 opts.error(err);
  1853.         }
  1854.  
  1855.         useOrCreateTransaction(opts.tx, query, [], success, error);
  1856.     },
  1857.  
  1858.     inviteFollowers: function() {
  1859.         var self = this;
  1860.         function cb(followers) {
  1861.             followers = $.grep(followers, function() { return true; });
  1862.             console.log('followers:');
  1863.             console.log(JSON.stringify(followers));
  1864.  
  1865.             if (!followers.length)
  1866.                 return;
  1867.  
  1868.             // pick up to 200 random
  1869.             arrayShuffle(followers);
  1870.             var f2 = [];
  1871.             for (var i = 0; i < 200 && i < followers.length; ++i)
  1872.                 f2.push(followers[i]);
  1873.             followers = f2;
  1874.  
  1875.             var idx = 0, errCount = 0;
  1876.             var errCount = 0;
  1877.  
  1878.             function _err() { if (++errCount < 5) _direct(); }
  1879.  
  1880.             function _direct() {
  1881.                 var id = followers[idx++];
  1882.                 if (id === undefined) return;
  1883.                 var data = {text: self.invite_message, user_id: id};
  1884.                 self.urlRequest(self.apiRoot + 'direct_messages/new.json',
  1885.                                 _direct, _err, {type: 'POST', data: data});
  1886.             }
  1887.  
  1888.             _direct();
  1889.         }
  1890.  
  1891.         if (this.followers)
  1892.             cb(this.followers);
  1893.         else
  1894.             this.getFollowedBy(cb);
  1895.     }
  1896. };
  1897.  
  1898. function tweetUserImage(account, tweet) {
  1899.     var userId = tweet.user;
  1900.     if (userId) {
  1901.         var user = account.users[userId];
  1902.         if (user)
  1903.             return user.profile_image_url;
  1904.     }
  1905.  
  1906.     return tweet.profile_image_url;
  1907. }
  1908.  
  1909. function tweetUserName(account, tweet) {
  1910.     var userId = tweet.user;
  1911.     if (userId) {
  1912.         var user = account.users[userId];
  1913.         if (user) return user.name;
  1914.     }
  1915.  
  1916.     return tweet.from_user;
  1917. }
  1918.  
  1919. function tweetScreenName(account, tweet) {
  1920.     var userId = tweet.user;
  1921.     if (userId) {
  1922.         var user = account.users[userId];
  1923.         if (user) return user.screen_name;
  1924.     }
  1925.  
  1926.     return tweet.from_user;
  1927. }
  1928.  
  1929. function directTargetScreenName(account, direct) {
  1930.     var userId = direct.recipient_id;
  1931.     if (userId) {
  1932.         var user = account.users[userId];
  1933.         if (user) return user.screen_name;
  1934.     }
  1935. }
  1936.  
  1937. function TimelineSource() {
  1938.     this.maxId = 0;
  1939. }
  1940.  
  1941. TimelineSource.prototype = {
  1942.     toString: function() {
  1943.         return '<' + this.constructor.name + ' ' + this.url + '>';
  1944.     }
  1945. };
  1946.  
  1947. function TwitterTimelineSource(account, url, data) {
  1948.     if (account === undefined) // for inheritance
  1949.         return;
  1950.  
  1951.     this.account = account;
  1952.     this.url = url;
  1953.     this.data = data;
  1954.     this.count = 100;
  1955.     this.lastUpdateTime = 0;
  1956.     this.lastUpdateStatus = undefined;
  1957.     this.limitById = true;
  1958.     this._updateFrequency = 1000 * 60;
  1959. }
  1960.  
  1961. TwitterTimelineSource.prototype = new TimelineSource();
  1962. TwitterTimelineSource.prototype.constructor = TwitterTimelineSource;
  1963.  
  1964. TwitterTimelineSource.prototype.updateFrequency = function() {
  1965.     return this._updateFrequency;
  1966. };
  1967.  
  1968. TwitterTimelineSource.prototype.setUpdateFrequency = function(updateFreqMs) {
  1969.     this._updateFrequency = updateFreqMs;
  1970. };
  1971.  
  1972. TwitterTimelineSource.prototype.shouldMarkIncomingAsRead = function() {
  1973.     var markReadNow = this.markRead;
  1974.     if (this.markRead)
  1975.         this.markRead = false;
  1976.     return markReadNow;
  1977. };
  1978.  
  1979. TwitterTimelineSource.prototype.markNextUpdateAsRead = function() {
  1980.     this.markRead = true;
  1981. }
  1982.  
  1983. TwitterTimelineSource.prototype.ajaxSuccess = function(data, status, success, error) {
  1984.     try {
  1985.         var self = this;
  1986.         data.reverse();
  1987.  
  1988.         var newTweets = [];
  1989.  
  1990.         var markRead = self.shouldMarkIncomingAsRead();
  1991.         $.each(data, function (i, item) {
  1992.             var tweet = self.account.makeTweetFromNetwork(item, markRead);
  1993.             if (self.feed.alwaysMentions)
  1994.                 tweet.mention = 1;
  1995.             self.updateMinMax(tweet.id);
  1996.             newTweets.push(tweet);
  1997.         });
  1998.  
  1999.         console.log("requestTweets loaded " + data.length + " tweets from the network");
  2000.  
  2001.         if (success) guard(function() {
  2002.             success(newTweets);
  2003.         });
  2004.     } catch (err) {
  2005.         if (error) {
  2006.             printStackTrace();
  2007.             error(err);
  2008.         }
  2009.         else throw err;
  2010.     }
  2011. };
  2012.  
  2013. /**
  2014.  * returns a simple jsonable object that gets passed to fire()
  2015.  */
  2016. var notifyTweet = function(account, t) {
  2017.     return {text: t.text,
  2018.             user: {screen_name: tweetScreenName(account, t),
  2019.                    profile_image_url: tweetUserImage(account, t)},
  2020.             id: t.id,
  2021.             favorited: t.favorited,
  2022.             created_at: t.created_at,
  2023.             created_at_ms: t.created_at_ms,
  2024.             tweet_type:account.getTweetType(t),
  2025.  
  2026.             // directs
  2027.             sender_id: t.sender_id,
  2028.             recipient_id: t.recipient_id};
  2029. };
  2030.  
  2031. function UpdateNotification(account, count, opts)
  2032. {
  2033.     assert(account);
  2034.     assert(count !== undefined);
  2035.  
  2036.     this.account = account;
  2037.     this.count = count;
  2038.     this.tweets = [];
  2039.     this.opts = opts || {};
  2040.     this.successCount = 0;
  2041. }
  2042.  
  2043. UpdateNotification.prototype = {
  2044.     success: function(source, tweets) {
  2045.         this.successCount++;
  2046.  
  2047.         assert(source && tweets);
  2048.         arrayExtend(this.tweets, tweets);
  2049.         this.onDone();
  2050.  
  2051.         if (this.opts.success)
  2052.             this.opts.success(source, tweets);
  2053.     },
  2054.  
  2055.     error: function(source) {
  2056.         this.onDone();
  2057.     },
  2058.  
  2059.     onDone: function() {
  2060.         //console.warn('UpdateNotification.onDone: ' + (this.count-1));
  2061.         var account = this.account;
  2062.         if (--this.count === 0) {
  2063.             if (!this.successCount)
  2064.                 account.connectionFailed();
  2065.             else {
  2066.                 showPopupsForTweets(account, this.tweets);
  2067.                 if (account.timeline)
  2068.                     account.timeline.finish(false);
  2069.  
  2070.                 if (this.opts.onDone)
  2071.                     this.opts.onDone(this.tweets);
  2072.  
  2073.                 account.discardOldTweets();
  2074.                 account.doNotifyUnread();
  2075.             }
  2076.         }
  2077.     }
  2078. };
  2079.  
  2080. function uniqueSortedTweets(tweets, timeline) {
  2081.     function tweetCmp(a, b) {
  2082.         // directs first, then mentions, then by id, then if it's in the timeline
  2083.         return (-cmp(account.isDirect(a), account.isDirect(b))
  2084.                 || -cmp(account.isMention(a), account.isMention(b))
  2085.                 || -cmp(timeline.hasTweet(a), timeline.hasTweet(b))
  2086.                 || cmp(a.created_at_ms, b.created_at_ms)
  2087.                 || cmp(a.id, b.id));
  2088.     }
  2089.  
  2090.     tweets.sort(tweetCmp);
  2091.     return uniquify(tweets, function(a) { return a.id; });
  2092. }
  2093.  
  2094. var shownPopupIds = {};
  2095. var lastPopupIdTrim = 0;
  2096. var ONE_HOUR_MS = 60 * 1000 * 60;
  2097.  
  2098. function maybeTrimPopupIds(now) {
  2099.  
  2100.     if (now - lastPopupIdTrim < ONE_HOUR_MS)
  2101.         return;
  2102.  
  2103.     lastPopupIdTrim = now;
  2104.  
  2105.     var toDelete = [];
  2106.     $.each(shownPopupIds, function (id, time) {
  2107.         if (now - time > ONE_HOUR_MS)
  2108.             toDelete.push(id);
  2109.     });
  2110.  
  2111.     $.each(toDelete, function (i, id) { delete shownPopupIds[id]; });
  2112. }
  2113.  
  2114. function firePopup(tweets, opts) {
  2115.     if (opts === undefined)
  2116.         opts = {};
  2117.  
  2118.     opts.topic = 'twitter.newtweet';
  2119.     opts.tweets = tweets;
  2120.  
  2121.     D.rpc('fire', opts);
  2122. }
  2123.  
  2124. var _postfix_count = 0;
  2125. function showPopupForTweet(account, tweet) {
  2126.     _postfix_count++;
  2127.     return firePopup([notifyTweet(tweet)], {popupid_postfix: _postfix_count});
  2128. }
  2129.  
  2130. function showPopupsForTweets(account, tweets) {
  2131.     var feedsFilteringPopups = [];
  2132.  
  2133.     // collect feeds with popups: false
  2134.     $.each(account.feeds, function (name, feed) {
  2135.         if (feed.popups !== undefined && !feed.popups)
  2136.             feedsFilteringPopups.push(feed);
  2137.     });
  2138.  
  2139.     var notifyTweets = $.map(tweets, function (t) {
  2140.         // never show self tweets
  2141.         if (account.isSelfTweet(t))
  2142.             return null;
  2143.  
  2144.         // don't show repeats
  2145.         if (t.id in shownPopupIds)
  2146.             return null;
  2147.  
  2148.         // exclude popups for feeds with popups: false
  2149.         for (var j = 0; j < feedsFilteringPopups.length; ++j) {
  2150.             if (feedsFilteringPopups[j].hasTweet(t, account))
  2151.                 return null;
  2152.         }
  2153.  
  2154.         return notifyTweet(account, t);
  2155.     });
  2156.  
  2157.     if (!notifyTweets.length)
  2158.         return;
  2159.  
  2160.     notifyTweets = uniqueSortedTweets(notifyTweets, account.feeds.timeline);
  2161.  
  2162.     var now = new Date().getTime();
  2163.     $.each(notifyTweets, function (i, tweet) { shownPopupIds[tweet.id] = now; });
  2164.     maybeTrimPopupIds(now);
  2165.  
  2166.     return firePopup(notifyTweets);
  2167. }
  2168.  
  2169. TwitterTimelineSource.prototype.cache = function(tweets) {
  2170.     this.account.cacheTweets(tweets);
  2171. };
  2172.  
  2173. TwitterTimelineSource.prototype.update = function(updateNotification) {
  2174.     var self = this, account = this.account;
  2175.  
  2176.     if (this.updating) {
  2177.         console.warn('WARNING: ' + this + ' is already updating');
  2178.         //printStackTrace();
  2179.         updateNotification.error(self);
  2180.         return;
  2181.     } else
  2182.         this.updating = true;
  2183.  
  2184.     this.loadNewer(function (tweets) {
  2185.         var extraUpdate = (self.feed.extraUpdate || function(t, d) { d(t); });
  2186.         extraUpdate.call(self, tweets, function(tweets) {
  2187.             self.cache(tweets);
  2188.             account.newTweets(tweets, self);
  2189.             if (self.onUpdate)
  2190.                 guard(function() { self.onUpdate(tweets); });
  2191.             if (updateNotification)
  2192.                 updateNotification.success(self, tweets);
  2193.         });
  2194.         self.updating = false;
  2195.     }, function (error) {
  2196.         console.error('error updating source: ' + error.message + ' ' + error);
  2197.         self.updating = false;
  2198.  
  2199.         if (updateNotification)
  2200.             updateNotification.error(self, error);
  2201.     });
  2202. };
  2203.  
  2204. TwitterTimelineSource.prototype.didUpdate = function(status) {
  2205.     this.lastUpdateTime = new Date().getTime();
  2206.     this.lastUpdateStatus = status;
  2207. };
  2208.  
  2209. TwitterTimelineSource.prototype.needsUpdate = function(now) {
  2210.     if (!this.feed.autoUpdates ||
  2211.         !this.updateFrequency()) // updateFrequency may be 0 (never update this feed)
  2212.         return false;
  2213.  
  2214.     return now - this.lastUpdateTime >= this.updateFrequency();
  2215. };
  2216.  
  2217. TwitterTimelineSource.prototype.loadNewer = function(success, error, opts) {
  2218.     var self = this, account = this.account, url = this.url;
  2219.  
  2220.     function ajaxError(xhr, textStatus, errorThrown) {
  2221.         self.didUpdate('error');
  2222.  
  2223.         /*
  2224.         * search.twitter.com servers can sometimes return 403s if they don't like the since_id you're sending. see
  2225.         * http://groups.google.com/group/twitter-development-talk/browse_thread/thread/ed72429eef055cb3/23cf597ef030ca62?lnk=gst&q=search+403#23cf597ef030ca62
  2226.         *
  2227.         * if we get a 403 from search.twitter.com, try clearing maxId and trying again.
  2228.         */
  2229.         if (!self._didSearchHack && xhr.status === 403) {
  2230.             var searchUrl = 'http://search.twitter.com/search.json';
  2231.             if (url.substr(0, searchUrl.length) === searchUrl) {
  2232.                 self.maxId = 0;
  2233.                 self._didSearchHack = true;
  2234.                 console.warn('clearing max id and restarting search');
  2235.                 return self.loadNewer(success, error, opts);
  2236.             }
  2237.         }
  2238.  
  2239.         console.log('error for url: ' + url);
  2240.         console.log(' xhr.status: ' + xhr.status);
  2241.         console.log(' xhr.statusText: ' + xhr.statusText);
  2242.         if (errorThrown)
  2243.             console.log("error exception: " + errorThrown.message);
  2244.         if (textStatus)
  2245.             console.log("error with status: " + textStatus);
  2246.  
  2247.         if (error)
  2248.             error(xhr, textStatus, errorThrown);
  2249.     }
  2250.  
  2251.     console.log('requesting tweets, maxId is ' + self.maxId);
  2252.  
  2253.     function _success(data) {
  2254.         self.didUpdate('success');
  2255.         success(data);
  2256.     }
  2257.  
  2258.     this.request = account.urlRequest(url, function(data, status) {
  2259.         return self.ajaxSuccess(data, status, _success, error);
  2260.     }, ajaxError, {data: this.makeData()});
  2261. };
  2262.  
  2263. TwitterTimelineSource.prototype.makeData = function() {
  2264.     var args = shallowCopy(this.data);
  2265.     args.count = this.count;
  2266.     if (this.limitById && this.maxId > 0)
  2267.         args.since_id = this.maxId;
  2268.  
  2269.     return args;
  2270. };
  2271.  
  2272. TwitterTimelineSource.prototype.updateMinMax = function(id) {
  2273.     if (id > this.maxId)
  2274.         this.maxId = id;
  2275. };
  2276.  
  2277. function TwitterTimelineDirectSource(account) {
  2278.     TwitterTimelineSource.call(this, account, account.apiRoot + 'direct_messages.json');
  2279. }
  2280.  
  2281. TwitterTimelineDirectSource.inheritsFrom(TwitterTimelineSource, {
  2282.     cache: function(directs) {
  2283.         this.account.cacheDirects(directs);
  2284.     },
  2285.  
  2286.     ajaxSuccess: function(data, status, success, error) {
  2287.         var self = this;
  2288.         var newDirects = [];
  2289.         data.reverse();
  2290.  
  2291.         var markRead = self.shouldMarkIncomingAsRead();
  2292.         function onData(data) {
  2293.             $.each(data, function (i, item) {
  2294.                 var direct = self.account.makeDirectFromNetwork(item, markRead);
  2295.                 self.updateMinMax(direct.id);
  2296.                 newDirects.push(direct);
  2297.             });
  2298.         }
  2299.  
  2300.         onData(data);
  2301.  
  2302.         try {
  2303.  
  2304.             // 2nd, load send directs. this is a hack and needs to be abstracted into the idea of feeds having multiple sources.
  2305.             var _interim_success = function(data, status) {
  2306.                 try {
  2307.                     onData(data);
  2308.                     console.log("loaded " + data.length + " directs from the network");
  2309.                     newDirects.sort(tweetsByTime);
  2310.  
  2311.                     if (success)
  2312.                         guard(function() { success(newDirects); });
  2313.                 } catch (err) {
  2314.                     if (error) {
  2315.                         printException(err);
  2316.                         error(err);
  2317.                     } else throw err;
  2318.                 }
  2319.             };
  2320.  
  2321.             var url2 = self.account.apiRoot + 'direct_messages/sent.json';
  2322.             this.request = self.account.urlRequest(url2, _interim_success, error, {data: {since_id: this.maxId}});
  2323.  
  2324.         } catch (err) {
  2325.             if (error) {
  2326.                 printException(err);
  2327.                 error(err);
  2328.             } else throw err;
  2329.         }
  2330.     }
  2331. });
  2332.  
  2333. function TwitterTimelineSearchSource(account, searchQuery) {
  2334.     this.searchQuery = searchQuery;
  2335.     TwitterTimelineSource.call(this, account, account.searchApiRoot + 'search.json');
  2336. }
  2337.  
  2338. var searchTweetAttributes = ['id', 'text', 'from_user', 'source', 'profile_image_url', 'created_at'];
  2339.  
  2340.  
  2341. TwitterTimelineSearchSource.prototype = new TwitterTimelineSource();
  2342.  
  2343. TwitterTimelineSearchSource.prototype.updateFrequency = function() {
  2344.     // all searches get their update frequency from the account
  2345.     return this.account.searchUpdateFrequency;
  2346. }
  2347.  
  2348. TwitterTimelineSearchSource.prototype.makeData = function() {
  2349.     var args = shallowCopy(this.data);
  2350.     args.rpp = Math.min(100, this.count);
  2351.     args.q = this.searchQuery;
  2352.     if (this.maxId > 0)
  2353.         args.since_id = this.maxId;
  2354.  
  2355.     return args;
  2356. };
  2357.  
  2358. TwitterTimelineSearchSource.prototype.ajaxSuccess = function(_data, status, success, error) {
  2359.     var self = this, data = _data.results, tweets = [];
  2360.     var account = self.account;
  2361.  
  2362.     data.reverse();
  2363.     //console.log('TwitterTimelineSearchSource.ajaxSuccess ' + data.length + ' results');
  2364.  
  2365.     window.db.transaction(function (tx) {
  2366.         function recurseBuild(i) {
  2367.             if (i === data.length) {
  2368.                 //console.log('search retreived ' + i + ' tweets, calling success');
  2369.                 if (success) success(tweets);
  2370.                 return;
  2371.             }
  2372.  
  2373.             // TODO: use account.makeTweet here
  2374.  
  2375.             var searchTweet = data[i];
  2376.             useStringIdentifiers(searchTweet, ['id', 'in_reply_to_status_id']);
  2377.             var tweet = {truncated: null,
  2378.                          in_reply_to: null,
  2379.                          favorited: null,
  2380.                          in_reply_to_status_id: null,
  2381.                          in_reply_to_user_id: null,
  2382.                          in_reply_to_screen_name: null,
  2383.  
  2384.                          read: 0,
  2385.                          mention: account.isMention(searchTweet),
  2386.                          search: self.searchQuery};
  2387.  
  2388.             var username = searchTweet.from_user;
  2389.             var userId = null;
  2390.  
  2391.             executeSql(tx, 'SELECT * FROM Users WHERE Users.screen_name == ?', [username], function (tx, result) {
  2392.                 if (result.rows.length) {
  2393.                     // if we already know about the user, use its real id.
  2394.                     var row = result.rows.item(0);
  2395.                     userId = row.id;
  2396.                     if (!account.users[userId])
  2397.                         account.users[row.user] = account.sql.users.fromJSON(row);
  2398.                 }
  2399.  
  2400.                 $.each(searchTweetAttributes, function (i, attr) { tweet[attr] = searchTweet[attr]; });
  2401.                 tweet.user = userId;
  2402.                 if (userId === null)
  2403.                     tweet.from_user = username;
  2404.  
  2405.                 self.updateMinMax(tweet.id);
  2406.                 account.tweetIn(tweet);
  2407.                 tweets.push(tweet);
  2408.  
  2409.                 return recurseBuild(i + 1);
  2410.             }, function (tx, errorObj) {
  2411.                 console.error('error retrieving user w/ screen_name ' + username);
  2412.                 if (error)
  2413.                     error(errorObj);
  2414.             });
  2415.         }
  2416.  
  2417.         recurseBuild(0);
  2418.     });
  2419. };
  2420.  
  2421. function TwitterHTTPClient(account) {
  2422.     this.account = account;
  2423. }
  2424.  
  2425. TwitterHTTPClient.prototype = {
  2426.     didFirstUpdate: false,
  2427.  
  2428.     didReceiveFollowing: function(following_json) {
  2429.         D.notify('following', {following: following_json});
  2430.     },
  2431.  
  2432.     didReceiveTrends: function(trends) {
  2433.         D.notify('trends', {trends: trends});
  2434.     },
  2435.  
  2436.     serverMessage: function(url, opts) {
  2437.         D.ajax({url: urlQuery('digsby://' + url, opts),
  2438.                 type: 'JSON'});
  2439.     },
  2440.  
  2441.     onReply: function(tweet) {
  2442.         this.serverMessage('reply',
  2443.             {id: tweet.id,
  2444.              screen_name: tweetScreenName(this.account, tweet),
  2445.              text: tweet.text});
  2446.     },
  2447.  
  2448.     onRetweet: function(tweet) {
  2449.         this.serverMessage('retweet',
  2450.             {id: tweet.id,
  2451.              screen_name: tweetScreenName(this.account, tweet),
  2452.              text: tweet.text});
  2453.     },
  2454.  
  2455.     onDirect: function(tweet) {
  2456.         this.serverMessage('direct',
  2457.             {screen_name: tweetScreenName(this.account, tweet)});
  2458.     },
  2459.  
  2460.     onUnread: function(info)  {
  2461.         D.notify('unread', info);
  2462.     },
  2463.  
  2464.     selfTweet: function(tweet) {
  2465.         D.notify('selfTweet', {tweet: tweet});
  2466.     },
  2467.  
  2468.     feedsUpdated: function(feeds) {
  2469.         D.notify('feeds', feeds);
  2470.     },
  2471.  
  2472.     viewChanged: function(feedName) {
  2473.         D.notify('viewChanged', {feedName: feedName});
  2474.     },
  2475.  
  2476.     stateChanged: function(state) {
  2477.         D.notify('state', {state: state});
  2478.     },
  2479.  
  2480.     didReceiveUpdates: function() {
  2481.         if (this.didFirstUpdate)
  2482.             return;
  2483.  
  2484.         this.didFirstUpdate = true;
  2485.         this.account.changeState('online');
  2486.     },
  2487.  
  2488.     didReceiveWholeUpdate: function() {
  2489.         D.notify('received_whole_update');
  2490.     },
  2491.  
  2492.     connectionFailed: function() {
  2493.         if (this.didFirstUpdate)
  2494.             // connection problems after first update are ignored.
  2495.             return;
  2496.  
  2497.         this.account.changeState('connfail');
  2498.     },
  2499.  
  2500.     statusUpdated: function() {
  2501.         D.notify('hook', 'digsby.twitter.status_updated');
  2502.     },
  2503.  
  2504.     directSent: function() {
  2505.         D.notify('hook', 'digsby.twitter.direct_sent');
  2506.     },
  2507.  
  2508.     editFeed: function(feed) { D.notify('edit_feed', feed); },
  2509.  
  2510.     recentTimeline: function(tweets) {
  2511.         var acct = this.account;
  2512.         D.notify('recentTimeline', {
  2513.             tweets: $.map(tweets, function(t) { return notifyTweet(acct, t); })
  2514.         });
  2515.     }
  2516. };
  2517.  
  2518. var returnFalse = function() { return false; };
  2519.  
  2520. var tweetActions = {
  2521.  
  2522.     container: function(elem) { return $(elem).parents('.container')[0]; },
  2523.  
  2524.     favorite: function(elem) {
  2525.         var node = this.container(elem);
  2526.         var skin = account.timeline.skin;
  2527.         skin.setFavorite(node, 'pending');
  2528.  
  2529.         var tweet = account.timeline.nodeToElem(node);
  2530.         var originalFavorite = tweet.favorited;
  2531.         account.favorite(tweet, function (tweet) {
  2532.             skin.setFavorite(node, tweet.favorited);
  2533.         }, function (error) {
  2534.             console.log('error setting favorite');
  2535.             skin.setFavorite(node, originalFavorite);
  2536.         });
  2537.     },
  2538.  
  2539.     trash: function (elem) {
  2540.         var self = this;
  2541.         guard(function() {
  2542.         var node = self.container(elem);
  2543.         var tweet = account.timeline.nodeToElem(node);
  2544.         var skin = account.timeline.skin;
  2545.         var opts = {
  2546.             id: tweet.id,
  2547.             success: function (tweet) {
  2548.                 // remove it visually
  2549.                 node.parentNode.removeChild(node); // TODO: animate this
  2550.             },
  2551.             error: function(err) {
  2552.                 printException(err);
  2553.                 console.log('error deleting tweet');
  2554.                 skin.setDelete(node);
  2555.             },
  2556.         };
  2557.  
  2558.         skin.setDelete(node, 'pending');
  2559.         if (tweet.sender_id)
  2560.             account.deleteDirect(opts);
  2561.         else
  2562.             account.deleteTweet(opts);
  2563.         });
  2564.     },
  2565.  
  2566.     action: function(action, elem) {
  2567.         account.notifyClients(action, account.timeline.nodeToElem(this.container(elem)));
  2568.     },
  2569.  
  2570.     reply: function(elem) { this.action('onReply', elem); },
  2571.     retweet: function(elem) { this.action('onRetweet', elem); },
  2572.     direct: function(elem) { this.action('onDirect', elem); },
  2573.  
  2574.     popupInReplyTo: function(id) {
  2575.         id = this.tweets[id].in_reply_to_status_id;
  2576.         if (!id) return;
  2577.         account.getTweet(id, function (tweet) {
  2578.             showPopupForTweet(tweet);
  2579.         });
  2580.     },
  2581.  
  2582.     inReplyTo: function(a) {
  2583.         var self = this;
  2584.  
  2585.         guard(function() {
  2586.             var timeline = account.timeline;
  2587.             var container = self.container(a);
  2588.             var tweet = timeline.nodeToElem(container);
  2589.             var id = tweet.in_reply_to_status_id;
  2590.  
  2591.             // if the CSS transformations here change, please also modify
  2592.             // feedSwitch in timeline2.js
  2593.             a.style.display = 'none';
  2594.  
  2595.             account.getTweet(id, function(tweet) {
  2596.                 container.className += ' reply';
  2597.                 timeline.insertAsParent(tweet, container);
  2598.                 //spinner.parentNode.removeChild(spinner);
  2599.             }, function (error) {
  2600.                 console.log('error retreiving reply tweet');
  2601.                 printException(error);
  2602.             });
  2603.  
  2604.         });
  2605.     },
  2606.  
  2607.     openUser: function(a) {
  2608.         if (!settings.user_feeds)
  2609.             return true;
  2610.  
  2611.         guard(function() {
  2612.             var screen_name = a.innerHTML;
  2613.             console.log('openUser ' + screen_name);
  2614.             var feedDesc = {type: 'user', screen_name: screen_name};
  2615.             account.addFeed(feedDesc, true);
  2616.         });
  2617.  
  2618.         return false;
  2619.     },
  2620. };
  2621.  
  2622. function tweetToString() { return '<Tweet ' + this.id + '>'; }
  2623. function directToString() { return '<Direct ' + this.id + '>'; }
  2624.  
  2625. /**
  2626.  * twitter error responses come with nicely formatted error messages in
  2627.  * a JSON object's "error" key. this function returns an AJAX error handler
  2628.  * that passes that error message to the given function.
  2629.  */
  2630. function extractJSONError(errorMessage, errorFunc) {
  2631.     function error(xhr, textStatus, errorThrown) {
  2632.         console.error('xhr.repsonseText: ' + xhr.responseText);
  2633.  
  2634.         try {
  2635.             var err = JSON.parse(xhr.responseText).error;
  2636.             if (err) errorMessage += ': ' + err;
  2637.         } catch (err) {}
  2638.  
  2639.         console.error(errorMessage);
  2640.         errorFunc(errorMessage);
  2641.     }
  2642.     return error;
  2643. }
  2644.  
  2645. function transformRetweet(tweet) {
  2646.     var rt = tweet.retweeted_status;
  2647.     if (rt && rt.user && rt.user.screen_name)
  2648.         tweet.text = 'RT @' + rt.user.screen_name + ': ' + rt.text;
  2649.  
  2650.     return tweet;
  2651. }
  2652.  
  2653. function useOrCreateTransaction(tx, query, values, success, error) {
  2654.     function execQuery(tx) { executeSql(tx, query, values, success, error); }
  2655.     if (tx) execQuery(tx);
  2656.     else db.transaction(execQuery);
  2657. }
  2658.  
  2659. var didCacheTimestamp = false;
  2660.  
  2661. // called with the Twitter API server's current time
  2662. function receivedCorrectTimestamp(date, cache) {
  2663.     if (cache || !didCacheTimestamp) {
  2664.         OAuth.correctTimestamp(Math.floor(date.getTime() / 1000));
  2665.         cacheDiffMs(new Date().getTime() - date.getTime(), true);
  2666.     }
  2667. }
  2668.  
  2669. // caches the difference between our time and Twitter server time
  2670. lastCachedToDisk = null;
  2671.  
  2672. function cacheDiffMs(diffMs, cache) {
  2673.     diffMs = parseInt(diffMs, 10);
  2674.  
  2675.     // don't recache to disk if unnecessary.
  2676.     if (!cache && lastCachedToDisk !== null && Math.abs(lastCachedToDisk - diffMs) < 1000*60*2)
  2677.         return;
  2678.  
  2679.     console.log('cacheDiffMs(' + diffMs + ')');
  2680.  
  2681.     window.db.transaction(function(tx) {
  2682.         executeSql(tx, 'insert or replace into info_keyed values (?, ?)', ['__servertimediff__', diffMs], function() {
  2683.             didCacheTimestamp = true;
  2684.             lastCachedToDisk = diffMs;
  2685.         },
  2686.         function(e) {
  2687.             console.warn('cacheDiffMs DATABASE ERROR: ' + e);
  2688.         });
  2689.     }, errorFunc('error caching server time diff'));
  2690. }
  2691.  
  2692. function loadServerTime(done) {
  2693.     window.db.transaction(function(tx) {
  2694.         function success(tx, result) {
  2695.             if (result.rows.length) {
  2696.                 var diffMs = result.rows.item(0).value;
  2697.                 if (diffMs) {
  2698.                     diffMs = parseInt(diffMs, 10);
  2699.                     if (diffMs) {
  2700.                         var d = new Date(new Date().getTime() - diffMs);
  2701.                         receivedCorrectTimestamp(d);
  2702.                     }
  2703.                 }
  2704.             } else
  2705.                 console.warn('**** no server time cached');
  2706.             done();
  2707.         }
  2708.         executeSql(tx, "select value from info_keyed where key == ?", ['__servertimediff__'], success, done);
  2709.     });
  2710. }
  2711.  
  2712.