noplanman / Tsu Friends and Followers

// ==UserScript==
// @name        Tsu Friends and Followers
// @namespace   tsu-friends-and-followers
// @description Tsu script to display Friends and Followers of all users on the current page
// @include     http://*tsu.co*
// @include     https://*tsu.co*
// @version     1.2
// @author      Armando Lüscher
// @grant       none
// ==/UserScript==

/**
 * Display Friends and Followers of all user links on the current page.
 * Automatically loads new data for newly appearing user links.
 *
 * waitForKeyElements is used in case the current browser does not support the MutationObserver.
 *
 * For changelog see https://github.com/noplanman/tsu-friends-and-followers/blob/master/CHANGELOG.md
 */

$( document ).ready(function () {

  /***************
  HELPER FUNCTIONS
  ***************/

  /**
   * Base64 library, just decoder: http://www.webtoolkit.info/javascript-base64.html
   * @param {string} e Base64 string to decode.
   */
  function base64_decode(e){var t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";var n="";var r,i,s;var o,u,a,f;var l=0;e=e.replace(/[^A-Za-z0-9\+\/\=]/g,"");while(l<e.length){o=t.indexOf(e.charAt(l++));u=t.indexOf(e.charAt(l++));a=t.indexOf(e.charAt(l++));f=t.indexOf(e.charAt(l++));r=o<<2|u>>4;i=(u&15)<<4|a>>2;s=(a&3)<<6|f;n=n+String.fromCharCode(r);if(a!=64){n=n+String.fromCharCode(i)}if(f!=64){n=n+String.fromCharCode(s)}}return n}

  /**
   * Check if a string starts with a certain string.
   */
  if ( 'function' !== typeof String.prototype.startsWith ) {
    String.prototype.startsWith = function( str ) {
      return ( this.slice( 0, str.length ) == str );
    };
  }

  /**
   * Check if a string ends with a certain string.
   */
  if ( 'function' !== typeof String.prototype.endsWith ) {
    String.prototype.endsWith = function( str ) {
      return ( this.slice( -str.length ) == str );
    };
  }

  /**
   * Check if a string contains a certain string.
   */
  if ( 'function' !== typeof String.prototype.contains ) {
    String.prototype.contains = function( str ) {
      return ( this.indexOf( str ) >= 0 );
    };
  }


  // Add the required CSS rules.
  addCSS();

  // Output console messages?
  var debug = false;

  // Remember the user objects that are currently loading.
  var userObjectsBusyLoading = [];

  // The user objects that have finished loading.
  var userObjects = [];

  // Max number of tries to get friend and follower info.
  var maxTries = 60;

  // Add all the sub nav items.
  addSubNavItems();

  // URL where to get the newest script.
  var scriptURL = 'https://greasyfork.org/scripts/6108-tsu-friends-and-followers/code/Tsu%20Friends%20and%20Followers.user.js';

  var localVersion = 1.2;
  var getVersionAPIURL = 'https://api.github.com/repos/noplanman/tsu-friends-and-followers/contents/VERSION';
  // Check for remote version number.
  checkRemoteVersion();


  // Get the current page and start the observer to automatically update user links.
  setTimeout( function() {
    getCurrentPage();
    startObserver();
  }, 500);

  // Pages with preloaded data should be processed instantly,
  // as the observer can't necessarily detect any change in those.
  setTimeout( function() {
    loadFriendsAndFollowers();
  }, 1500);


  var currentPage;
  /**
   * Get the current page to know which queries to load and observe and
   * also for special cases of how the Friends and Followers details are loaded.
   */
  function getCurrentPage() {
    doLog( 'Getting current page.' );
    if( $( 'body.newsfeed' ).length ) {
      // Home feed.
      currentPage    = 'home';
      queryToLoad    = '.evac_user';
      queryToObserve = 'body.newsfeed';
    } else if ( $( 'body.discover' ).length ) {
      currentPage    = 'discover';
      queryToLoad    = 'body.discover .tree_child_fullname';
      // No observer necessary!
      // TODO: Also, with observer, this page goes crazy...
      queryToObserve = '';
    } else if ( $( '#profile_feed' ).length ) {
      // Diary
      currentPage    = 'diary';
      queryToLoad    = '.evac_user';
      queryToObserve = '#profile_feed';
    } else if ( $( 'body.tree' ).length ) {
      // Family tree.
      currentPage    = 'tree';
      queryToLoad    = 'body.tree .tree_child_fullname';
      queryToObserve = '.tree_page';
    } else if ( document.URL.contains( '/post/' ) ) {
      // Single post.
      currentPage    = 'post';
      queryToLoad    = '.evac_user';
      queryToObserve = '.post';
    } else if ( document.URL.endsWith( '/friends' ) ) {
      // Friends.
      currentPage    = 'friends';
      queryToLoad    = '.card .card_sub .info';
      queryToObserve = '.profiles_list';
    } else if ( document.URL.endsWith( '/followers' ) ) {
      // Followers.
      currentPage    = 'followers';
      queryToLoad    = '.card .card_sub .info';
      queryToObserve = '.profiles_list';
    } else if ( document.URL.endsWith( '/following' ) ) {
      // Following.
      currentPage    = 'following';
      queryToLoad    = '.card .card_sub .info';
      queryToObserve = '.profiles_list';
    } else if ( document.URL.contains( '/messages/' ) || document.URL.endsWith( '/messages' ) ) {
      // Messages.
      currentPage    = 'messages';
      queryToLoad    = '.message_box .content';
      queryToObserve = '.messages_content';
    }
    doLog( 'Current page: ' + currentPage );
  }


  // The elements that we are looking for. Get set in getCurrentPage();
  var queryToLoad;
  /**
   * Load Friends and Followers
   * @param {boolean} clean Delete saved details and refetch all.
   */
  function loadFriendsAndFollowers( clean ) {
    if ( clean ) {
      if ( confirm( 'Reload all Friend and Follower details of all users on the current page?' ) ) {
        doLog( '- (clean) Start loading Friends and Followers.' );

        // Reset lists.
        userObjectsBusyLoading = [];
        userObjects = [];

        // Remove all existing user links spans and brs.
        $( '.tff-span, .tff-br' ).remove();
        $( '.tff-processed' ).removeClass( 'tff-processed' );
      } else {
        return;
      }
    } else {
      doLog( '- Start loading Friends and Followers.' );
    }

    // Special case for "Discover Users".
    if ( 'discover' == currentPage ) {
      $( '#discover .user_card_1_wrapper' ).each(function() {
        $currentUserLink = $( this ).find( '.tree_child_fullname' );
        var userID;
        var classes = $( this ).find( '.follow_button' ).attr( 'class' ).split( ' ' );
        var search = 'follow_button_';
        for ( var i = classes.length - 1; i >= 0; i-- ) {
          if ( search == classes[i].slice( 0, search.length ) ) {
            userID = classes[i].substr( search.length );
            break;
          }
        }

        // Make the username a link to the profile.
        $currentUserLink.html( $( '<a/>', {
          href: '/users/' + userID,
          html: $currentUserLink.html()
        }));
      });
    }

    // Find all users and process them.
    $allUserLinks = $( queryToLoad );
    doLog( 'User links found: ' + $allUserLinks.length );
    $allUserLinks.each(function() {
      var $currentUserLink = $( this );

      // If this link is on a tooltip, ignore it!
      if ( $currentUserLink.closest( '.tooltipster-base' ).length ) {
        return;
      }

      // Try to get a numeric user id.
      var userID = $currentUserLink.attr( 'user_id' );

      // Special case for comments, because the html is "a.evac_user" instead of a nested entry "div.evac_user a".
      if ( 'a' != $currentUserLink.prop( 'tagName' ).toLowerCase() ) {
        $currentUserLink = $currentUserLink.find( 'a:first' );
      }

      // If no link has been found, continue with the next one. Fail-safe.
      if ( 0 === $currentUserLink.length ) {
        return;
      }

      // If the user link has already been processed, continue with the next one.
      if ( $currentUserLink.hasClass( 'tff-processed' ) || $currentUserLink.siblings( '.tff-span' ).length ) {
        return;
      }

      // Add a new <span> element to the user link.
      var $userLinkSpan = $( '<span/>', { html: '<img class="tff-loader-wheel" src="/assets/loader.gif" title="Loading..." />', 'class': 'tff-span' } );
      $currentUserLink.after( $userLinkSpan );

      // Special case for these pages, to make it look nicer and fitting.
      if ( 'friends' == currentPage || 'followers' == currentPage || 'following' == currentPage || 'discover' == currentPage ) {
        $userLinkSpan.before( '<br class="tff-br" />' );
      }

      // Get the user info from the link.
      var userName = $currentUserLink.text().trim();
      var userUrl  = $currentUserLink.attr( 'href' );

      // Special case for the Discover page to get the userID.
      if ( 'discover' == currentPage ) {
        userID = userUrl.split( '/' )[2];
      } else {
        userID = userID || userUrl.split( '/' )[1];
      }

      // Check if the current user has already been loaded.
      if ( userBusyLoading( userID, true ) ) {
        refreshUserObject( userID, $userLinkSpan, 0 );
        $currentUserLink.addClass( 'tff-processed' );
        return;
      }

      var loadedUserObject = userLoaded( userID );
      if ( loadedUserObject instanceof UserObject ) {
        refreshUserObject( loadedUserObject, $userLinkSpan, 0 );
        $currentUserLink.addClass( 'tff-processed' );
        return;
      }

      // Load the numbers from the user profile page.
      $( '<span/>' ).load( userUrl + ' .profile_details .numbers', function( response, status ) {
        if ( 'success' == status ) {
          var $numbers = $( 'a', this );

          var $friends = $( $numbers[0] );
          var friendsUrl = $friends.attr( 'href' );
          var friendsCount = $friends.find( 'span' ).text();
          var $friendsLink = $( '<a/>', {
            title: 'Friends',
            href: friendsUrl,
            html: friendsCount
          });

          var $followers = $( $numbers[1] );
          var followersUrl = $followers.attr( 'href' );
          var followersCount = $followers.find( 'span' ).text();
          var $followersLink = $( '<a/>', {
            title: 'Followers',
            href: followersUrl,
            html: followersCount
          });

          // Add the user details after the user link.
          refreshUserObject( userID, $userLinkSpan );

          addUserObject( userID, userName, userUrl, $friendsLink, friendsUrl, friendsCount, $followersLink, followersUrl, followersCount );
          $currentUserLink.addClass( 'tff-processed' );

        } else if ( 'error' == status ) {
          doLog( response, 'e' );
          $(this).html( 'n/a' );
        }

        // Make sure to set the user as finished loading.
        finishedLoading( userID );
      });
    });
  }

  var busyLoading = false;
  /**
   * If already busy loading, just wait a while before initiating another load.
   */
  var delayLoadFriendsAndFollowers = function() {
    if ( ! busyLoading ) {
      loadFriendsAndFollowers();
      setTimeout(function() {
        busyLoading = false;
      }, 2000 );
    }
    busyLoading = true;
  };


  /**
   * UserObject "class".
   * @param {string|integer} userID         Depending on the context, this is either the user id as a number or unique username / identifier.
   * @param {string}         userName       The user's full name.
   * @param {string}         userUrl        The url to the user profile page.
   * @param {jQuery}         $friendsLink   The jQuery <a> object linking to the user's Friends page.
   * @param {[string}        friendsUrl     The URL to the user's Friends page.
   * @param {[string}        friendsCount   The user's number of friends.
   * @param {jQuery}         $followersLink The jQuery <a> object linking to the user's Followers page.
   * @param {string}         followersUrl   The URL to the user's Followers page.
   * @param {string}         followersCount The user's number of Followers.
   */
  function UserObject( userID, userName, userUrl, $friendsLink, friendsUrl, friendsCount, $followersLink, followersUrl, followersCount ) {
    this.userID         = userID.trim();
    this.userName       = userName.trim();
    this.userUrl        = userUrl.trim();
    this.$friendsLink   = $friendsLink;
    this.friendsUrl     = friendsUrl.trim();
    this.friendsCount   = friendsCount.trim();
    this.$followersLink = $followersLink;
    this.followersUrl   = followersUrl.trim();
    this.followersCount = followersCount.trim();

    // Return a clone of the Friends page link.
    this.getFriendsLink = function() {
      return this.$friendsLink.clone();
    };

    // Return a clone of the Followers page link.
    this.getFollowersLink = function() {
      return this.$followersLink.clone();
    };
  }

  /**
   * Add a user object to the userObjects array.
   * See param info for UserObject().
   */
  function addUserObject( userID, userName, userUrl, $friendsLink, friendsUrl, friendsCount, $followersLink, followersUrl, followersCount ) {
    userObjects.push( new UserObject( userID, userName, userUrl, $friendsLink, friendsUrl, friendsCount, $followersLink, followersUrl, followersCount ) );
    doLog( '(' + userID + ':' + userName + ') New user loaded.' );
  }

  /**
   * Check to see if the user is still being loaded.
   * @param  {integer|string} userID     User ID as a numeric value or username string.
   * @param  {boolean}        setLoading If the user isn't being loaded yet, should it be set as loading?
   * @return {boolean}                   If the user is busy loading.
   */
  function userBusyLoading( userID, setLoading ) {
    for ( var i = userObjectsBusyLoading.length - 1; i >= 0; i-- ) {
      if ( userID == userObjectsBusyLoading[i] ) {
        return true;
      }
    }
    // If the user isn't loading and it should be set, add to loading list.
    if ( setLoading ) {
      userObjectsBusyLoading.push( userID );
    }
    return false;
  }

  /**
   * A user object has finished loading, remove it from the busyLoading array.
   * @param  {integer|string} userID User ID as a numeric value or username string.
   */
  function finishedLoading( userID ) {
    for ( var i = userObjectsBusyLoading.length - 1; i >= 0; i-- ) {
      if ( userID == userObjectsBusyLoading[i] ) {
        doLog( '(id:' + userID + ') Finished loading.' );
        delete userObjectsBusyLoading[i];
        return;
      }
    }
  }

  /**
   * Check if a user has already been loaded, if so, return the requested user object.
   * @param  {integer|string}     userID User ID as a numeric value or username string.
   * @return {boolean|UserObject}        If the user has been loaded, return user object, else return false.
   */
  function userLoaded( userID ) {
    for ( var i = userObjects.length - 1; i >= 0; i-- ) {
      if ( userID == userObjects[i].userID ) {
        doLog( '(' + userObjects[i].userID + ':' + userObjects[i].userName + ') Already loaded.' );
        return userObjects[i];
      }
    }
    doLog( '(id:' + userID + ') Not loaded yet.' );
    return false;
  }

  /**
   * Refresh the passed $userLinkSpan with the user details.
   * @param  {integer|UserObject} userID        The userID or userObject to get the details from.
   * @param  {jQuery}             $userLinkSpan The <span> jQuery object to appent the details to.
   * @param  {integer}            tries         The number of tries that have already been used to refresh the details.
   */
  function refreshUserObject( userID, $userLinkSpan, tries ) {
    if ( undefined === tries || null === tries ) {
      tries = 0;
    }

    // If the user object has been passed, use it and reset the userID. Otherwise, get the loaded user object if available.
    var userObject;
    if ( userID instanceof UserObject ) {
      userObject = userID;
      userID = userObject.userID;
    } else {
      userObject = userLoaded( userID );
    }

    // If the maximum tries has been exeeded, return.
    if ( tries > maxTries ) {
      // Just remove the failed link span, maybe it will work on the next run.
      $userLinkSpan.remove();

      doLog( '(id:' + userID + ') Maximum tries exeeded!', 'w' );
      return;
    }

    if ( userObject instanceof UserObject ) {
      // Add the user details after the user link.
      $userLinkSpan.empty().append( userObject.getFriendsLink(), userObject.getFollowersLink() );
      doLog( '(' + userObject.userID + ':' + userObject.userName + ') Friends and Followers set.' );
    } else {
      setTimeout(function() {
        refreshUserObject( userID, $userLinkSpan, tries + 1);
      }, 500);
    }
  }


  // The elements that we are observing. Get set in getCurrentPage().
  var queryToObserve;
  /**
   * Start observing for DOM changes.
   */
  function startObserver() {
    // The discover page needs special treatement here, as it doesn't need an observer.
    if ( 'discover' == currentPage ) {
      return;
    }

    doLog( 'Start Observer.', 'i' );
    // Check if we can use the MutationObserver.
    if ( 'MutationObserver' in window ) {
      var toObserve = document.querySelector( queryToObserve );
      if ( toObserve ) {
        var observer = new MutationObserver( function( mutations ) {
          doLog( mutations.length + ' DOM changes.' );
          doLog( mutations );

          var reload = false;
          // Ignore post and comment time updates.
          for ( var i = mutations.length - 1; i >= 0; i-- ) {
            var classes = mutations[i].target.className;
            if ( ! classes.contains( 'comment_time_from_now' ) && ! classes.contains( 'time_to_update' ) ) {
              reload = true;
              break;
            }
          }

          if ( reload ) {
            delayLoadFriendsAndFollowers();
          }
        });

        // Observe child and subtree changes.
        observer.observe( toObserve, {
          childList: true,
          subtree: true
        });
      }
    } else {
      // If we have no MutationObserver, use "waitForKeyElements" function.
      // Instead of using queryToObserve, we wait for the ones that need to be loaded, queryToLoad.
      $.getScript( 'https://gist.github.com/raw/2625891/waitForKeyElements.js', function() {
        waitForKeyElements( queryToLoad, delayLoadFriendsAndFollowers );
      });
    }
  }


  /**
   * Add the required CSS rules.
   */
  function addCSS() {
    doLog( 'Added CSS.' );
    $( '<style>' )
      .html( '\
        .tff-span a {\
          font-size: smaller;\
          margin-left: 5px;\
          border-radius: 3px;\
          background-color: #1ABC9C;\
          color: #fff !important;\
          padding: 2px 4px 0;\
        }\
        .tff-span .tff-loader-wheel {\
          margin-left: 5px;\
          height: 12px;\
        }\
        .user_card_1_wrapper .tree_child_fullname {\
          height: 32px !important;\
        }\
        #tff-menuitem-update a:before {\
          display: none !important;\
        }\
      ')
      .appendTo( 'head' );
  }

  /**
   * Add sub nav items to the menu.
   */
  function addSubNavItems() {
    doLog( 'Loading Sub Nav Items...' );

    // Load Friends and Followers
    if ( 0 === $( '#tff-menuitem-load-friends-and-followers' ).length ) {
      var $loadFriendsAndFollowersAnchor = $( '<a/>', { html: 'Load Friends and Followers' } );
      $loadFriendsAndFollowersAnchor.click( function() { loadFriendsAndFollowers( true ); } );

      var $loadFriendsAndFollowersListItem = $( '<li/>', { 'id': 'tff-menuitem-load-friends-and-followers' ,html: $loadFriendsAndFollowersAnchor } );

      $( '#navBarHead .sub_nav' ).append( $loadFriendsAndFollowersListItem );
      doLog( '- "Load Friends and Followers" appended.' );
    }

    doLog( 'Sub Nav Items loaded.' );
  }


  /**
   * Make a log entry if debug mode is active.
   * @param {string}  logMessage Message to write to the log console.
   * @param {string}  level      Level to log ([l]og,[i]nfo,[w]arning,[e]rror).
   * @param {boolean} alsoAlert  Also echo the message in an alert box.
   */
  function doLog( logMessage, level, alsoAlert ) {
    if ( debug ) {
      switch( level ) {
        case 'i': console.info( logMessage );  break;
        case 'w': console.warn( logMessage );  break;
        case 'e': console.error( logMessage ); break;
        default: console.log( logMessage );
      }
      if ( alsoAlert ) {
        alert( logMessage );
      }
    }
  }

  /**
   * Get the remote version on GitHub and add a menuitem and notification if a newer version is found.
   */
  function checkRemoteVersion() {
    $.getJSON( getVersionAPIURL, function ( response ) {
      var remoteVersion = parseFloat( base64_decode( response.content ) );
      doLog( 'Versions: Local (' + localVersion + '), Remote (' + remoteVersion + ')', 'i' );

      // Check if there is a newer version available.
      if ( remoteVersion > localVersion ) {
        // Change the background color of the name tab on the top right.
        $( '#navBarHead .tab.name' ).css( 'background-color', '#F1B054' );

        // Make sure the update link doesn't already exist!
        if ( 0 === $( '#tff-menuitem-update' ).length ) {
          var $updateLink = $( '<a/>', {
            title: 'Update Friends and Followers script to the newest version (' + remoteVersion + ')',
            href: scriptURL,
            html: 'Update Tsu F&F!'
          })
          .attr( 'target', '_blank' ) // Open in new window / tab.
          .css( { 'background-color' : '#F1B054', 'color' : '#fff' } ) // White text on orange background.
          .click(function() {
            if ( ! confirm( 'Upgrade to the newest version (' + remoteVersion + ')?\n\n(refresh this page after the script has been updated)' ) ) {
              return false;
            }
          });

          $( '<li/>', { 'id': 'tff-menuitem-update', html: $updateLink } )
          .appendTo( '#navBarHead .sub_nav' );
        }

      }
    })
    .fail(function() { doLog( 'Couldn\'t get remote version number for TFF.', 'w' ); });
  }

})();