vicrram / Tsu Helper

// ==UserScript==
// @name        Tsu Helper
// @namespace   tsu-helper
// @description Tsu script that adds a bunch of tweaks to make Tsu more user friendly.
// @include     http://*tsu.co*
// @include     https://*tsu.co*
// @version     2.5
// @copyright   2014-2015 Armando Lüscher
// @author      Armando Lüscher
// @oujs:author noplanman
// @grant       none
// @homepageURL https://j.mp/tsu-helper
// @supportURL  https://j.mp/tsu-helper-issues
// ==/UserScript==

/**
 * For changelog see https://j.mp/tsu-helper-changelog
 */

/**
 * How nice of you to visit! I've tried to make this code as clean as possible with lots of
 * comments for everybody to learn from.
 *
 * Because that is what this life is about, to learn from each other and to grow together!
 *
 * If you have any questions, ideas, feature requests, (pretty much anything) about it, just ask :-)
 *
 * Simply visit the GitHub page here: https://j.mp/tsu-helper-issues and choose "New Issue".
 * I will then get back to you as soon as I can ;-)
 */

// Make sure we have jQuery loaded.
if ( ! ( 'jQuery' in window ) ) { return false; }

// Run everything as soon as the DOM is set up.
jQuery( document ).ready(function( $ ) {

  // Display Debug options? (for public).
  var publicDebug = false;

  /**
   * 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.
  'function'!=typeof String.prototype.startsWith&&(String.prototype.startsWith=function(t){return this.slice(0,t.length)==t;});

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

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

  // Add stringify to jQuery (https://gist.github.com/chicagoworks/754454).
  jQuery.extend({stringify:function(r){if('JSON'in window)return JSON.stringify(r);var n=typeof r;if('object'!=n||null===r)return'string'==n&&(r='"'+r+'"'),String(r);var t,i,e=[],o=r&&r.constructor==Array;for(t in r)i=r[t],n=typeof i,r.hasOwnProperty(t)&&('string'==n?i='"'+i+'"':'object'==n&&null!==i&&(i=jQuery.stringify(i)),e.push((o?'':'"'+t+'":')+String(i)));return(o?'[':'{')+String(e)+(o?']':'}');}});

  // Serialize form data to save settings (http://stackoverflow.com/questions/1184624/convert-form-data-to-js-object-with-jquery).
  $.fn.serializeObject=function(){var i={},e=this.serializeArray();return $.each(e,function(){void 0!==i[this.name]?(i[this.name].push||(i[this.name]=[i[this.name]]),i[this.name].push(this.value||"")):i[this.name]=this.value||"";}),i;};

  /**
   * Like WP, return "selected" attribute text.
   * @param  {string} val1 Value to compare.
   * @param  {string} val2 Value to compare with.
   * @return {string}      "Selected" text or nothing.
   */
  function selected( val1, val2 ) {
    return ( val1 === val2 ) ? ' selected="selected"' : '';
  }

  /**
   * Like WP, return "checked" attribute text.
   * @param  {string} val1 Value to compare.
   * @param  {string} val2 Value to compare with.
   * @return {string}      "Checked" text or nothing.
   */
  function checked( val1, val2 ) {
    // Compare to "true" by default.
    val2 = ( undefined === val2 ) ? true : val2;
    return ( val1 === val2 ) ? ' checked="checked"' : '';
  }


  /**
   * All settings related methods and variables.
   */
  var Settings = {
    // All available settings with default values.
    settingsDefault : {
      debugLevel    : 'disabled',  // Debugging level. (disabled,[l]og,[i]nfo,[w]arning,[e]rror)
      hideAds       : false,       // Hide all ads.
      showFFC       : 'hovercard', // Show Friends and Followers count. (disabled,all,hovercard)
      quickMention  : true,        // Add quick mention links.
      emphasizeNRP  : true,        // Emphasize nested replies parents.
      checkSocial   : true,        // Check the social network sharing.
      checkMaxHM    : true,        // Check for maximum hashtags and mentions.
      notifReloaded : 10           // How many items to display on the Notifications popup (0=disabled)
    },

    // Init with default settings on "load".
    settings : {},

    // Name used for the settings cookie.
    cookieName : 'tsu-helper-settings',

    /**
     * Set default settings.
     */
    setDefaults : function( $form ) {
      Settings.populateForm( $form, true );
    },

    /**
     * Load settings from cookie.
     */
    load : function() {
      // Init with defaults and add all loaded settings.
      $.extend( true, Settings.settings, Settings.settingsDefault );

      var savedJSON = $.cookie( Settings.cookieName );
      if ( savedJSON ) {
        $.extend( Settings.settings, $.parseJSON( savedJSON ) );
      }

      return Settings.settings;
    },

    /**
     * Populate the passed form with the current settings.
     * @param {jQuery}  $form    The form to be populated.
     * @param {boolean} defaults Load the default values?
     */
    populateForm : function( $form, defaults ) {
      if ( $form ) {
        for ( var setting in Settings.settings ) {
          if ( Settings.settings.hasOwnProperty( setting ) ) {
            var $input = $( '[name=' + setting + ']', $form );
            var val = ( defaults ) ? Settings.settingsDefault[ setting ] : Settings.settings[ setting ];
            if ( 'checkbox' === $input.attr( 'type' ) ) {
              $input.prop( 'checked', val );
            } else {
              $input.val( val );
            }
          }
        }
      }
    },

    /**
     * Save settings to cookie.
     */
    save : function( $form ) {
      // First save?
      if ( undefined === $.cookie( Settings.cookieName ) && ! confirm( 'Settings will be saved in a cookie. Ok?' ) ) {
        return false;
      }

      // If a form is passed, use those values.
      if ( $form ) {
        // Default to false and then get form settings.
        // This is necessary for checkbox inputs as they are assigned by checked state, not value.
        Settings.settings.hideAds      = false;
        Settings.settings.quickMention = false;
        Settings.settings.emphasizeNRP = false;
        Settings.settings.checkSocial  = false;
        Settings.settings.checkMaxHM   = false;

        $.extend( Settings.settings, $form.serializeObject() );
      }

      $.cookie( Settings.cookieName, $.stringify( Settings.settings ), { expires: 999, path: '/' } );
      return true;
    }
  };
  // Load settings from cookie.
  var settings = Settings.load();


  /**
   * All updater and version related variables.
   */
  var Updater = {
    // The local version.
    localVersion : 2.5,

    // The remote version (loaded in the "check" method).
    remoteVersion : null,

    // URL where to get the newest script.
    scriptURL : 'https://openuserjs.org/install/noplanman/Tsu_Helper.user.js',

    // Version details.
    versionAPIURL : 'https://api.github.com/repos/noplanman/tsu-helper/contents/VERSION',

    // Get the remote version on GitHub.
    init : function() {
      try {
        var response = $.ajax({
          type: 'GET',
          url: Updater.versionAPIURL,
          async: false
        }).fail(function() {
          doLog( 'Couldn\'t get remote version number for Tsu Helper.', 'w' );
        }).responseJSON;

        // Set the remote version.
        Updater.remoteVersion = parseFloat( base64_decode( response.content ) );
        doLog( 'Versions: Local (' + Updater.localVersion + '), Remote (' + Updater.remoteVersion + ')', 'i' );
      } catch( e ) {
        doLog( 'Couldn\'t get remote version number for Tsu Helper.', 'w' );
      }
    },

    /**
     * Is there a newer version available?
     * @return {Boolean} If there is a newer version available.
     */
    hasUpdate : function() {
      return ( Updater.remoteVersion > Updater.localVersion );
    }
  };
  // Initialise the updater to fetch the remote version.
  Updater.init();


  /**
   * TSU constants.
   */
  var TSUConst = {
    // Define the maximum number of hashtags and mentions allowed.
    maxHashtags : 10,
    maxMentions : 10,

    // Cards per page request of Friends, Followers and Following tabs.
    ffmCardsPerPage : 12,

    // Texts for all possible notifications.
    kindsTexts : {
      friend_request_accepted              : 'Friend Requests accepted',
      new_comment_on_post                  : 'Comments on your Posts',
      new_comment_on_post_you_commented_on : 'Comments on other Posts',
      new_follower                         : 'New Followers',
      new_like_on_post                     : 'Likes on your Posts',
      new_like_on_comment                  : 'Likes on your Comments',
      new_post_on_your_wall                : 'Posts on your Wall',
      someone_mentioned_you_in_a_post      : 'Mentioned in a Post or Comment',
      someone_shared_your_post             : 'Shares of your Posts',
      donation_received                    : 'Donations received',
      someone_joined_your_network          : 'Users who joined your Network'
    }
  };


  /**
   * Page related things.
   */
  var Page = {

    // The current page.
    current : '',

    /**
     * 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.
     */
    init : function() {
      doLog( 'Getting current page.', 'i' );

      if ( $( 'body.newsfeed' ).length ) {                 Page.current = 'home';          // Home feed.
      } else if ( $( 'body.notifications.show' ).length
        || $( 'body.notifications.index' ).length ) {      Page.current = 'notifications'; // Show notifications.
      } else if ( $( 'body.search_hashtag' ).length ) {    Page.current = 'hashtag';       // Hashtag page.
      } else if ( $( 'body.profile.diary' ).length ) {     Page.current = 'diary';         // Diary.
      } else if ( $( 'body.show_post' ).length ) {         Page.current = 'post';          // Single post.
      } else if ( $( 'body.discover' ).length ) {          Page.current = 'discover';      // Discover Users.
        Observer.queryToLoadFF  = 'body.discover .tree_child_fullname';
        Observer.queryToObserve = ''; // No observer necessary!
      } else if ( $( 'body.dashboard' ).length ) {         Page.current = 'analytics';     // Analytics.
        Observer.queryToObserve = ''; // No observer necessary!
      } else if ( $( 'body.tree' ).length ) {              Page.current = 'tree';          // Family tree.
        Observer.queryToLoadFF  = 'body.tree .tree_child_fullname';
        Observer.queryToObserve = '.tree_page';
      } else if ( $( 'body.profile.friends' ).length ) {   Page.current = 'friends';       // Friends.
      } else if ( $( 'body.profile.followers' ).length ) { Page.current = 'followers';     // Followers.
      } else if ( $( 'body.profile.following' ).length ) { Page.current = 'following';     // Following.
      } else if ( $( 'body.messages' ).length ) {          Page.current = 'messages';      // Messages.
        Observer.queryToLoad    = '.messages_content .message_box';
        Observer.queryToLoadFF  = '.message_box .content';
        Observer.queryToObserve = '.messages_content';
      }

      // Group queries to load.
      if ( Page.is( 'has-posts' ) ) {
        queryToLoad = '.comment';
        // Add userlinks to query?
        switch ( settings.showFFC ) {
          case 'all'       : Observer.queryToLoadFF = '.card .card_sub .info, .evac_user'; break;
          case 'hovercard' : Observer.queryToLoadFF = '.card .card_sub .info';             break;
        }
      } else if ( 'all' === settings.showFFC && Page.is( 'fff' ) ) {
        Observer.queryToLoadFF = '.card .card_sub .info';
      }

      Observer.queryToLoad += ',' + Observer.queryToLoadFF;

      doLog( 'Current page: ' + Page.current, 'i' );
    },

    /**
     * Check if the passed page is the current one.
     * @param  {string}  pages Comma seperated list of pages.
     * @return {boolean}       If the current page is in the list.
     */
    is : function( pages ) {
      // To make things easier, allow shortcuts.
      pages = pages.replace( /has-userlinks/g, 'has-posts fff messages discover tree' );
      pages = pages.replace( /has-posts/g, 'home hashtag diary post' );
      pages = pages.replace( /fff/g, 'friends followers following' );

      // Make an array.
      pages = pages.split( ' ' );

      // Is the current page in the passed page list?
      for ( var i = pages.length - 1; i >= 0; i-- ) {
        if ( Page.current === pages[i] ) {
          return true;
        }
      }
      return false;
    }
  };


  /**
   * Friends and Followers Manager.
   */
  var FFM = {

    // The total amount of pages to preload.
    totalPages : 0,

    // The real number of Friends / Followers / Following.
    totalFFFs  : 0,
    $totalFFFs : null,

    // List of active Ajax requests.
    ajaxRequests : {},

    // The current chain request.
    $ajaxChainRequest : null,

    // Is the search busy?
    busy      : false,
    chainBusy : false,

    // The number of found friend requests.
    counter : {
      fandf    : 0,
      received : 0,
      sent     : 0
    },
    filterCheckboxes : {},

    // The buttons and status text.
    $linkCancel          : null,
    $linkStart           : null,
    $statusText          : null,
    $linkUnfollowFriends : null,

    /**
     * Initialise the Friends and Followers Manager.
     */
    init : function() {
      // Make sure we're on the right page.
      if ( ! Page.is( 'fff' ) ) {
        return;
      }

      doLog( 'Loading FF Manager.', 'i' );

      // Only possible on the current users profile.
      if ( window.current_user.username === $( '.profile_details .summary .username' ).text().trim().substring( 1 ) ) {
        var $title = $( '.profiles_list .title' );

        // Get the number of pages required to load all users in the list, 12 per page.
        FFM.totalPages = Math.ceil( /\d+/.exec( $title.text() ) / TSUConst.ffmCardsPerPage ) || 1;
        doLog( 'Total number of pages to load: ' + FFM.totalPages, 'i' );
        // As this number isn't totally correct, load all the pages
        // and chain-load from the last page as far as needed.

        // Real number of FFFs.
        FFM.$totalFFFs = $( '<span/>', {
          'id'  : 'th-ffm-total-fffs',
          title : 'Correct count',
          html  : '-'
        })
        .hide() // Start hidden.
        .appendTo( $title );

        // Cancel link.
        FFM.$linkCancel = $( '<a/>', {
          'id'  : 'th-ffm-link-cancel',
          title : 'Cancel current search',
          html  : 'Cancel',
          click : function() { FFM.cancel(); }
        })
        .hide() // Start hidden.
        .appendTo( $title );

        // Start link.
        FFM.$linkStart = $( '<a/>', {
          'id'  : 'th-ffm-link-start',
          title : 'Search for pending Friend Requests and Friends you also Follow.',
          html  : 'F&F Manager',
          click : function() { FFM.start(); }
        })
        .appendTo( $title );

        // Status text to display the number of found items.
        FFM.$statusText = $( '<span/>', {
          'id' : 'th-ffm-status-text',
          html :
            '<label title="Friends also being Followed" class="th-ffm-card-fandf"><span>0</span> F&F</label>&nbsp;' +
            '<label title="Received Friend Requests" class="th-ffm-card-received"><span>0</span> received</label>&nbsp;' +
            '<label title="Sent Friend Requests" class="th-ffm-card-sent"><span>0</span> sent</label>'
        })
        .hide() // Start hidden.
        .appendTo( $title );

        // Assign checkbox clicks to show / hide results.
        $( '<input/>', { 'id' : 'th-ffm-cb-fandf',    type : 'checkbox', checked : 'checked' } ).change( function() { FFM.filter( 'fandf',    this.checked ); } ).prependTo( FFM.$statusText.find( '.th-ffm-card-fandf' ) );
        $( '<input/>', { 'id' : 'th-ffm-cb-received', type : 'checkbox', checked : 'checked' } ).change( function() { FFM.filter( 'received', this.checked ); } ).prependTo( FFM.$statusText.find( '.th-ffm-card-received' ) );
        $( '<input/>', { 'id' : 'th-ffm-cb-sent',     type : 'checkbox', checked : 'checked' } ).change( function() { FFM.filter( 'sent',     this.checked ); } ).prependTo( FFM.$statusText.find( '.th-ffm-card-sent' ) );

        FFM.$linkUnfollowFriends = $( '<a/>', {
          'id'  : 'th-ffm-unfollow-friends',
          title : 'Unfollow all Friends on this page',
          html  : 'unfollow',
          click : function() { FFM.unfollowFriends(); }
        })
        .hide() // Start hidden.
        .prependTo( FFM.$statusText );
      }
    },

    /**
     * Update the status text counts.
     * @param  {string} type The type of card to count.
     */
    updateStatus : function( type ) {
      if ( null === type ) {
        FFM.$statusText.find( 'span' ).html( '0' );
        FFM.$totalFFFs.html( '-' );
        return;
      }
      FFM.$statusText.find( '.th-ffm-card-' + type + ' span' ).html( ++FFM.counter[ type ] );
    },

    /**
     * Call getPage but with a delay to prevent flooding the server.
     * @param  {integer} delay   Delay in ms.
     * @param  {integer} pageNr  The page number to get.
     * @param  {boolean} isChain If this a chain request, continue getting consecutive pages.
     */
    getPageDelay : function( delay, pageNr, isChain ) {
      setTimeout( function() { FFM.getPage( pageNr, isChain ); }, delay );
    },

    /**
     * Get a page of Follower/Following cards.
     * @param  {integer} pageNr  The page number to get.
     * @param  {boolean} isChain If this a chain request, continue getting consecutive pages.
     */
    getPage : function( pageNr, isChain ) {
      // Has the user cancelled the chain?
      if ( isChain && ! FFM.chainBusy ) {
        doLog( 'Jumped out of chain.', 'i' );
        return;
      }

      // Make sure we have a valid page number.
      if ( pageNr < 1 ) {
        doLog( 'Invalid page number:' + pageNr, 'e' );
        return;
      }

      doLog( 'Getting page ' + pageNr, 'l' );

      var fetch_url = '/users/profiles/users_list/' + window.current_user.id + '/' + Page.current + '/' + pageNr;

      var $ajaxCurrentRequest = $.get( fetch_url, function( data ) {
        // Get all the cards.
        var $cards = $( data ).siblings( '.card' );

        // If we have only 1 card, there are no siblings, so get the card itself.
        if ( 0 === $cards.length ) {
          $cards = $( data );
        }

        // Count each card to determine correct total count.
        FFM.totalFFFs += $cards.length;
        FFM.$totalFFFs.html( FFM.totalFFFs );

        if ( $cards.length ) {
          // Flag each card and add to stack.
          $cards.each(function() {
            var $card = $( this );

            // Is this card a friend?
            var $friendButton = $card.find( '.friend_button.grey' );
            if ( $friendButton.length ) {

              var $followButton = $card.find( '.follow_button.grey' );
              var type;
              // Find only respond and request links. Also show cards that are friends and following.
              if ( $friendButton.hasClass( 'friend_request_box' ) ) {
                type = 'received';
              } else if ( $friendButton.attr( 'href' ).contains( '/cancel/' ) ) {
                type = 'sent';
              } else if ( $followButton.length ) {
                type = 'fandf';
              } else {
                // Next card.
                return;
              }

              // Add card and update the status text.
              $( '.profiles_list .card:not(.th-ffm-card):first' ).before( $card.addClass( 'th-ffm-card th-ffm-card-' + type ) );
              FFM.updateStatus( type );
            }
          });
        }

        // Are there more pages to load?
        if ( isChain ) {
          if ( $( data ).siblings( '.loadmore_profile' ).length ) {
            // Get the next page.
            FFM.getPage( pageNr + 1, true );
          } else {
            doLog( 'Chain completed on page ' + pageNr, 'i' );
            FFM.chainBusy = false;
          }
        }
      })
      .fail(function( xhr, status, error ) {
        if ( 'abort' === status ) {
          doLog( 'Aborted page ' + pageNr, 'e' );
        } else {
          doLog( 'Error on page ' + pageNr + '! (' + status + ':' + error + ')', 'e' );
        }
      })
      .always(function() {
        doLog( 'Finished page ' + pageNr, 'l' );
        // After the request is complete, remove it from the array.
        delete FFM.ajaxRequests[ pageNr ];
        FFM.checkIfFinished();
      });

      // If this is a chain request, set the $ajaxChainRequest variable.
      // If not, add it to the requests array.
      if ( isChain ) {
        FFM.$ajaxChainRequest = $ajaxCurrentRequest;
      } else {
        FFM.ajaxRequests[ pageNr ] = $ajaxCurrentRequest;
      }
    },

    /**
     * Check if the Friend Request search is finished.
     */
    checkIfFinished : function() {
      var activeRequests = Object.keys( FFM.ajaxRequests ).length;
      doLog( activeRequests + ' pages left.', 'l' );

      // If no chain is active and the requests are completed, we're finished!
      if ( ! FFM.chainBusy && 0 === activeRequests ) {
        FFM.finished();
      }
    },

    /**
     * Start the FFM.
     */
    start : function() {
      if ( ! confirm( 'WARNING: This may temporarily block access to Tsu!\n\nAre you really sure you want to start the F&F Manager?' ) ) {
        return;
      }

      FFM.busy = FFM.chainBusy = true;
      FFM.totalFFFs = FFM.counter.received = FFM.counter.sent = FFM.counter.fandf = 0;
      FFM.updateStatus( null );
      FFM.$statusText.find( 'input' ).attr( 'disabled', true ).prop( 'checked', true );
      FFM.$statusText.show();
      FFM.$totalFFFs.show();
      FFM.$linkUnfollowFriends.hide();

      // Clear any previous results.
      $( '.th-ffm-card' ).remove();

      // Load all pages and start the chain loading on the last page.
      for ( var i = FFM.totalPages; i >= 1; i-- ) {
        FFM.getPageDelay( 50, i, i === FFM.totalPages );
      }

      FFM.$linkStart.hide();
      FFM.$linkCancel.show();
    },


    /**
     * Cancel the FFM.
     */
    cancel : function() {
      // Call finished with the cancelled parameter.
      FFM.finished( true );
    },

    /**
     * Finish off the FFM.
     * @param  {boolean} cancelled If the FFM has been cancelled.
     */
    finished : function( cancelled ) {
      FFM.busy = FFM.chainBusy = false;
      FFM.$statusText.find( 'input' ).removeAttr( 'disabled' );

      // If followed riends are found, show the "unfollow" button.
      if ( FFM.counter.fandf ) {
        FFM.$linkUnfollowFriends.show();
      }

      if ( cancelled ) {
        // Abort all AJAX requests.
        for ( var page in FFM.ajaxRequests ) {
          FFM.ajaxRequests[ page ].abort();
          delete FFM.ajaxRequests[ page ];
        }

        // Abort the current AJAX chain request.
        FFM.$ajaxChainRequest.abort();

        // The total number is incomplete, so hide it.
        FFM.$totalFFFs.hide();
      }

      FFM.$linkCancel.hide();
      FFM.$linkStart.show();
    },

    /**
     * Display / Hide certain categories.
     * @param  {string}  type  The type of cards to display / hide.
     * @param  {boolean} state True: display, False: hide.
     */
    filter : function( type, state ) {
      if ( state ) {
        $( '.th-ffm-card.th-ffm-card-' + type ).show();
      } else {
        $( '.th-ffm-card.th-ffm-card-' + type ).hide();
      }
    },

    /**
     * Automatically Unfollow all the loaded Friends.
     */
    unfollowFriends : function() {
      var $toUnfollow = $( '.th-ffm-card.th-ffm-card-fandf .follow_button.grey' );
      if ( $toUnfollow.length && confirm( 'WARNING: This may temporarily block access to Tsu!\n\nAre you really sure you want to Unfollow all ' + $toUnfollow.length + ' Friends on this page?\nThey will still be your Friends.\n\n(this cannot be undone and may take some time, be patient)' ) ) {
        var unfollowed = 0;
        $toUnfollow.each(function() {
          this.click();
          unfollowed++;
        });
        FFM.$linkUnfollowFriends.hide();
        alert( unfollowed + ' Friends have been Unfollowed!');
      }
    }
  };


  /**
   * Quick Mention links.
   */
  var QM = {

    // The currently active textarea to insert the @mentions.
    $activeReplyTextArea : null,

    /**
     * Add text to the passed textarea input field.
     * @param {jQuery} $textArea jQuery object of the textarea input field.
     * @param {string} text      Text to add.
     */
    addTextToTextArea : function( $textArea, text ) {
      if ( $textArea ) {
        var textAreaText = $textArea.val();
        var caretPos1 = $textArea[0].selectionStart;
        var caretPos2 = $textArea[0].selectionEnd;
        $textArea.val( textAreaText.substring( 0, caretPos1 ) + text + textAreaText.substring( caretPos2 ) );
        $textArea[0].selectionStart = $textArea[0].selectionEnd = caretPos1 + text.length;
        $textArea.focus();
      }
    },

    /**
     * Add the @mention links to the replies.
     */
    load : function() {
      // Make sure the setting is enabled and we're on the right page.
      if ( ! settings.quickMention || ! Page.is( 'has-posts' ) ) {
        return;
      }

      doLog( 'Adding Quick Mention links.', 'i' );

      // Process all reply links to autofocus the reply textarea input field.
      $( '.load_more_post_comment_replies' ).not( '.th-qm-reply-processed' ).each(function() {
        var $replyLink = $( this );
        $replyLink.click(function() {
          var $postComment    = $replyLink.closest( '.post_comment' );
          var $replyContainer = $postComment.siblings( '.comment_reply_container' );
          var $textArea       = $replyContainer.children( '.post_write_comment' ).find( '#comment_text' );

          // This gets called before the "official" click, so the logic is inversed!
          // And delay everything a bit too, as it gets lazy-loaded.
          if ( $replyContainer.is( ':visible' ) ) {
            setTimeout(function() {
              // Only set the active textarea null if it's this one.
              if ( $textArea[0] === QM.$activeReplyTextArea[0] ) {
                QM.$activeReplyTextArea = null;
                // Hide all @ links.
                $( '.th-qm-reply' ).hide();
              }
            }, 100);
          } else {
            setTimeout(function() {
              $postComment.find( '.th-qm-reply' ).show();
              $textArea.focus();
            }, 100);
          }
        });
        $replyLink.addClass( 'th-qm-reply-processed' );
      });

      // Process all comment / reply textarea input fields to set themselves as active on focus.
      $( '.post_write_comment #comment_text' ).not( '.th-qm-textarea-processed' ).each(function() {
        $( this ).focusin( function() {
          QM.$activeReplyTextArea = $( this );
          $( '.th-qm-active-input' ).removeClass( 'th-qm-active-input' );
          QM.$activeReplyTextArea.closest( '.expandingText_parent' ).addClass( 'th-qm-active-input' );
        });
        $( this ).addClass( 'th-qm-textarea-processed' );
      });

      // Link for all comments.
      $( '.post_comment_header' ).not( '.th-qm-added' ).each(function() {
        var $head = $( this );
        var $commentArea = $head.closest( '.post_comment' );

        // Get just the last part of the href, the username.
        var hrefBits = $head.find( 'a' ).attr( 'href' ).split( '/' );
        var atUsername = '@' + hrefBits[ hrefBits.length - 1 ] + ' ';

        var $mentionLink = $( '<a/>', {
          class : 'th-qm-reply',
          html  : '@ +',
          title : 'Add ' + atUsername + 'to current reply.',
          click : function() {
            QM.addTextToTextArea( QM.$activeReplyTextArea, atUsername );
          }
        })
        .hide(); // Start hidden, as it will appear with the mouse over event.

        // Show / hide link on hover / blur if there is an active reply input selected.
        $commentArea.hover(
          function() { if ( QM.$activeReplyTextArea && QM.$activeReplyTextArea.length ) { $mentionLink.show(); } },
          function() { $mentionLink.hide(); }
        );

        $head.addClass( 'th-qm-added' );

        // Position the @ link.
        var $profilePic = $head.find( '.post_profile_picture' );
        var offset = $profilePic.position();
        $mentionLink.offset({ top: offset.top + $profilePic.height(), left: offset.left });

        $head.append( $mentionLink );
      });

      // Link for all textareas.
      $( '.post_write_comment' ).not( '.th-qm-added' ).each(function() {
        var $commentArea = $( this );
        var $commentInput = $commentArea.find( '#comment_text' );
        var $head = null;
        var linkElement = null;
        var isReply = $commentArea.hasClass( 'reply' );

        // Is this a nested comment? Then use the previous reply as the username.
        if ( isReply ) {
          $head = $commentArea.closest( '.comment' ).find( '.post_comment .post_comment_header' );
          linkElement = 'a';
        } else {
          // Get the current post to determine the username.
          var $post = $commentArea.closest( '.post' );

          // Defaults as if we have a shared post.
          $head = $post.find( '.share_header' );
          linkElement = '.evac_user a';

          // If it's not a share, get the post header.
          if ( 0 === $head.length ) {
            $head = $post.find( '.post_header' );
            linkElement = '.post_header_pp a';
          }
        }

        // Get just the last part of the href, the username.
        var hrefBits = $head.find( linkElement ).attr( 'href' ).split( '/' );
        var atUsername = '@' + hrefBits[ hrefBits.length - 1 ] + ' ';

        var $mentionLink = $( '<a/>', {
          class : 'th-qm-comment',
          html  : '@ >',
          title : 'Add ' + atUsername + 'to this ' + ( ( isReply ) ? 'reply.' : 'comment.' ),
          click : function() {
            QM.addTextToTextArea( $commentInput, atUsername );
          }
        })
        .hide(); // Start hidden, as it will appear with the mouse over event.

        // Show / hide link on hover / blur.
        $commentArea.hover(
          function() { $mentionLink.show(); },
          function() { $mentionLink.hide(); }
        );

        $commentArea.addClass( 'th-qm-added' );

        $commentArea.find( '.post_profile_picture' ).parent().after( $mentionLink );
      });
    }
  };


  /**
   * The MutationObserver to detect page changes.
   */
  var Observer = {

    // The mutation observer object.
    observer : null,

    // The elements that we are observing.
    queryToObserve : 'body',
    // The query of objects that trigger the observer.
    queryToLoad    : '',
    // The query of userlinks to look for.
    queryToLoadFF  : '',

    /**
     * Start observing for DOM changes.
     */
    init : function() {

      // Check if we can use the MutationObserver.
      if ( 'MutationObserver' in window ) {
        // Are we observing anything on this page?
        if ( '' === Observer.queryToObserve ) {
          return;
        }

        var toObserve = document.querySelector( Observer.queryToObserve );

        if ( toObserve ) {

          doLog( 'Started Observer.', 'i' );

          Observer.observer = new MutationObserver( function( mutations ) {

            function itemsInArray( needles, haystack ) {
              for ( var i = needles.length - 1; i >= 0; i-- ) {
                if ( $.inArray( needles[ i ], haystack ) > -1 ) {
                  return true;
                }
              }
              return false;
            }

            // Helper to determine if added or removed nodes have a specific class.
            function mutationNodesHaveClass( mutation, classes ) {
              classes = classes.split( ' ' );

              // Added nodes.
              for ( var ma = mutation.addedNodes.length - 1; ma >= 0; ma-- ) {
                var addedNode = mutation.addedNodes[ ma ];
                // In case the node has no className (e.g. textnode), just ignore it.
                if ( 'className' in addedNode && 'string' === typeof addedNode.className && itemsInArray( addedNode.className.split( ' ' ), classes ) ) {
                  return true;
                }
              }

              // Removed nodes.
              for ( var mr = mutation.removedNodes.length - 1; mr >= 0; mr-- ) {
                var removedNode = mutation.removedNodes[ mr ];
                // In case the node has no className (e.g. textnode), just ignore it.
                if ( 'className' in removedNode && 'string' === typeof removedNode.className && itemsInArray( removedNode.className.split( ' ' ), classes ) ) {
                  return true;
                }
              }

              return false;
            }

            doLog( mutations.length + ' DOM changes.' );
            doLog( mutations );

            // Only react to changes we're interested in.
            for ( var m = mutations.length - 1; m >= 0; m-- ) {
              var $hoverCard = $( '.tooltipster-user-profile' );

              // Are we on a hover card?
              if ( $hoverCard.length && mutationNodesHaveClass( mutations[ m ], 'tooltipster-user-profile' ) ) {
                FFC.loadUserHoverCard( $hoverCard.find( '.card .card_sub .info' ) );
              }

              // Run all functions responding to DOM updates.
              // When loading a card, only if it's not a hover card, as those get loaded above.
              if ( mutationNodesHaveClass( mutations[ m ], 'post comment message_content_feed message_box tree_bar tree_child_wrapper' )
                || ( mutationNodesHaveClass( mutations[ m ], 'card' ) && $hoverCard.length === 0 ) ) {
                FFC.loadAll();
                QM.load();
                emphasizeNestedRepliesParents();
                tweakMessagesPage();
              }
            }
          });

          // Observe child and subtree changes.
          Observer.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() {

          doLog( 'Started Observer (waitForKeyElements).', 'i' );

          // !! Specifically check for the correct page to prevent overhead !!

          if ( Page.is( 'has-userlinks' ) ) {
            waitForKeyElements( Observer.queryToLoad, FFC.loadAll() );
            //waitForKeyElements( Observer.queryToLoad, delayLoadFriendsAndFollowers );
          }
          if ( Page.is( 'has-posts' ) ) {
            waitForKeyElements( Observer.queryToLoad, QM.load );
            waitForKeyElements( Observer.queryToLoad, emphasizeNestedRepliesParents );
          }
          if ( Page.is( 'messages' ) ) {
            waitForKeyElements( Observer.queryToLoad, tweakMessagesPage );
          }
        });
      }
    }
  };


  /**
   * Post related things.
   */
  var Posting = {

    // Are we busy waiting for the popup to appear?
    waitingForPopup : false,

    /**
     * Initialise.
     */
    init : function() {
      // Remind to post to FB and Twitter in case forgotten to click checkbox.
      $( '#create_post_form' ).submit(function( event ) {
        return Posting.postFormSubmit( $( this ), event );
      });

      // Set focus to message entry field on page load.
      if ( Page.is( 'home diary' ) ) {
        $( '#text' ).focus();
      }

      // When using the "Create" or "Message" buttons, wait for the post input form.
      $( 'body' ).on( 'click', '.create_post_popup, .message_pop_up', function() {
        if ( ! Posting.waitingForPopup ) {
          if ( $( this ).hasClass( 'create_post_popup' ) ) {
            Posting.waitForPopup( 'post' );
          } else if ( $( this ).hasClass( 'message_pop_up' ) ) {
            Posting.waitForPopup( 'message' );
          }
        }
      });

      // Auto-focus title entry field when adding title.
      $( 'body' ).on( 'click', '.create_post .options .add_title', function() {
        var $postForm  = $( this ).closest( '#create_post_form' );
        var $postTitle = $postForm.find( '#title' );
        // Focus title or text field, depending if the title is being added or removed.
        if ( $postTitle.is( ':visible' ) ) {
          setTimeout( function() { $postForm.find( '#text' ).focus(); }, 50 );
        } else {
          setTimeout( function() { $postTitle.focus(); }, 50 );
        }
      });

      // Auto-focus message entry field when adding/removing image.
      $( 'body' ).on( 'click', '.create_post .options .filebutton, .cancel_icon_createpost', function() {
        var $postText = $( this ).closest( '#create_post_form' ).find( '#text' );
        setTimeout( function() { $postText.focus(); }, 50 );
      });


      /**
       * Open post by double clicking header (only on pages with posts).
       */
      if ( ! Page.is( 'has-posts' ) ) {
        return;
      }

      $( 'body' ).on( 'dblclick', '.post_header_name, .share_header', function( event ) {
        var $post      = $( this ).closest( '.post' );
        var isShare    = $post.find( '.share_header' ).length;
        var isOriginal = ! $( this ).hasClass( 'share_header' );
        $post.find( '#post_link_dropdown a' ).each(function() {
          var linkText = $( this ).text().trim().toLowerCase();
          if ( ( ! isShare && 'open' === linkText )
            || ( ! isOriginal && 'open' === linkText )
            || ( isOriginal && 'open original post' === linkText ) ) {

            var url = $( this ).attr( 'href' );
            // If the shift key is pressed, open in new window / tab.
            if ( event.shiftKey ) {
              window.open( url, '_blank' ).focus();
            } else {
              window.location = url;
            }
            return;
          }
        });
      });
    },

    /**
     * Check for the maximum number of hashtags and mentions.
     * @param  {string}  message The message being posted.
     * @return {boolean}         True = submit, False = cancel, Null = not too many
     */
    checkMaximumHashtagsMentions : function( message ) {
      // Check if the setting is enabled.
      if ( ! settings.checkMaxHM ) {
        return null;
      }

      // Get number of hashtags and mentions in the message.
      var nrOfHashtags = message.split( '#' ).length - 1;
      doLog( nrOfHashtags + ' Hashtags found.' );
      var nrOfMentions = message.split( '@' ).length - 1;
      doLog( nrOfMentions + ' Mentions found.' );

      // If the limits aren't exeeded, just go on to post.
      if ( nrOfHashtags <= TSUConst.maxHashtags && nrOfMentions <= TSUConst.maxMentions ) {
        return null;
      }

      // Set up warning message.
      var warning = 'Limits may be exceeded, check your message!\nAre you sure you want to continue?\n';
      if ( nrOfHashtags > TSUConst.maxHashtags ) {
        warning += '\n' + nrOfHashtags + ' #hashtags found. (Max. ' + TSUConst.maxHashtags + ')';
        doLog( 'Too many hashtags found! (' + nrOfHashtags + ')', 'w' );
      }
      if ( nrOfMentions > TSUConst.maxMentions ) {
        warning += '\n' + nrOfMentions + ' @mentions found. (Max. ' + TSUConst.maxMentions + ')';
        doLog( 'Too many mentions found! (' + nrOfMentions + ')', 'w' );
      }

      // Last chance to make sure about hashtags and mentions.
      return confirm( warning );
    },

    /**
     * Check if the social network sharing has been selected.
     * @param  {jQuery}  $form Form jQuery object of the form being submitted.
     * @return {boolean}       True = submit, False = cancel, Null = all selected
     */
    checkSocialNetworkSharing : function( $form ) {
      // Check if the setting is enabled.
      if ( ! settings.checkSocial ) {
        return null;
      }

      var share_facebook = null;
      var share_twitter  = null;

      // Get all visible (connected) checkboxes. If any are not checked, show warning.
      $form.find( '.checkboxes_options_create_post input:visible' ).each(function() {
        switch ( $( this ).attr( 'id' ) ) {
          case 'facebook': share_facebook = $( this ).prop( 'checked' ); break;
          case 'twitter':  share_twitter  = $( this ).prop( 'checked' ); break;
        }
      });

      // If no social network accounts are connected, just go on to post.
      if ( false !== share_facebook && false !== share_twitter ) {
        return null;
      }

      var post_to = 'OK = Post to Tsu';

      // Share to facebook?
      if ( true === share_facebook ) {
        post_to += ', Facebook';
      }
      // Share to twitter?
      if ( true === share_twitter ) {
        post_to += ', Twitter';
      }

      // Last chance to enable sharing to social networks...
      return confirm( post_to + '\nCancel = Choose other social networks' );
    },

    /**
     * Called on form submit.
     * @param  {jQuery} $form Form jQuery object of the form being submitted.
     * @param  {event}  event The form submit event.
     */
    postFormSubmit : function( $form, event ) {
      // In case the post gets cancelled, make sure the message field is focused.
      var message = $form.find( '#text' ).focus().val();
      var title   = $form.find( '#title' ).val();
      var hasPic  = ( '' !== $form.find( '#create_post_pic_preview' ).text() );

      // Make sure something was entered (title, text or image.
      // Check for the maximum number of hashtags and mentions,
      // and if the Social network sharing warning has been approved.
      if ( ( '' !== message || '' !== title || hasPic )
        && false !== Posting.checkMaximumHashtagsMentions( message )
        && false !== Posting.checkSocialNetworkSharing( $form ) ) {
        doLog( 'Post!' );
        return;
      }


      /**************************
      * CANCEL FORM SUBMISSION! *
      **************************/
      doLog( 'DONT Post!' );

      // Prevent form post.
      event.preventDefault();

      // Hide the loader wheel.
      $form.find( '.loading' ).hide();

      // Make sure to enable the post button again. Give it some time, as Tsu internal script sets it to disabled.
      setTimeout(function(){
        $form.find( '#create_post_button' ).removeAttr( 'disabled' );
      }, 500 );

      return false;
    },

    /**
     * Wait for the fancybox popup to create a new post.
     */
    waitForPopup : function( action ) {
      Posting.waitingForPopup = true;

      var formSelector;
      var inputSelector;
      switch ( action ) {
        case 'post'    :
          formSelector  = '.fancybox-wrap #create_post_form';
          inputSelector = '#text';
          break;
        case 'message' :
          formSelector  = '.fancybox-wrap #new_message';
          inputSelector = '#message_body';
          break;
      }

      var $form = $( formSelector );
      if ( $form.length ) {
        $form.find( inputSelector ).focus();

        // Apply checks to posts only!
        if ( 'post' === action ) {
          $form.submit(function( event ) {
            return Posting.postFormSubmit( $( this ), event );
          });
        }
        Posting.waitingForPopup = false;
        return;
      }

      // Wait around for it longer...
      setTimeout( function() { Posting.waitForPopup( action ); }, 500 );
    }
  };


  /**
   * User object containing info for Friends and Followers counter.
   * @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.
   */
  function UserObject( userID, userName, userUrl ) {

    // Keep track if this user object has already finished loading.
    this.finished = false;
    this.userID   = userID;
    this.userName = userName;
    this.userUrl  = userUrl;

    // Add to all userObjects list.
    Users.userObjects[ userID ] = this;

    // Queue of user link spans to refresh once the user object has finished loading.
    this.userLinkSpans = [];

    doLog( '(' + userID + ':' + userName + ') New user loaded.' );

    /**
     * Set the friends info.
     * @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.
     */
    this.setFriendsInfo = function( $friendsLink, friendsUrl, friendsCount ) {
      this.$friendsLink   = $friendsLink;
      this.friendsUrl     = friendsUrl;
      this.friendsCount   = friendsCount;
    };

    /**
     * Set the followers info.
     * @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.
     */
    this.setFollowersInfo = function( $followersLink, followersUrl, followersCount ) {
      this.$followersLink   = $followersLink;
      this.followersUrl     = followersUrl;
      this.followersCount   = followersCount;
    };

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

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

    /**
     * Set this user object as finished loading.
     */
    this.setFinished = function() {
      this.finished = true;
      doLog( '(id:' + this.userID + ') Finished loading.' );
    };

    /**
     * Is this user object already loaded?
     * @return {Boolean}
     */
    this.isFinished = function() {
      return this.finished;
    };

    /**
     * Add a user link span to the queue to be refreshed once the user object is loaded.
     * @param  {jQuery} $userLinkSpan The user link span object.
     */
    this.queueUserLinkSpan = function( $userLinkSpan ) {
      this.userLinkSpans.push( $userLinkSpan );
    };

    /**
     * Refresh the passed $userLinkSpan with the user details.
     * @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.
     */
    this.refresh = function( $userLinkSpan, tries ) {
      if ( undefined === tries || null === tries ) {
        tries = 0;
      }

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

        // Remove all queued ones too to prevent infinite loader image.
        for ( var i = this.userLinkSpans.length - 1; i >= 0; i-- ) {
          this.userLinkSpans[ i ].remove();
        }
        this.userLinkSpans = [];

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

      if ( this.isFinished() ) {
        // Add the user details after the user link.
        this.queueUserLinkSpan( $userLinkSpan );

        // Update all listening user link spans.
        for ( var i = this.userLinkSpans.length - 1; i >= 0; i-- ) {
          this.userLinkSpans[ i ].empty().append( this.getFriendsLink(), this.getFollowersLink() );
        }

        // Empty the queue, as there is no need to reload already loaded ones.
        this.userLinkSpans = [];

        doLog( '(' + this.userID + ':' + this.userName + ') Friends and Followers set.' );
      } else {
        var t = this;
        setTimeout(function() {
          t.refresh( $userLinkSpan, tries + 1);
        }, 1000);
      }
    };
  }

  /**
   * Friends and Followers counts manager.
   */
  var FFC = {

    // Max number of tries to get friend and follower info (= nr of seconds).
    maxTries : 60,

    /**
     * Load a user link.
     * @param  {jQuery}  $userElement The element that contains the user link.
     * @param  {boolean} onHoverCard  Is this element a hover card?
     */
    loadUserLink : function( $userElement, onHoverCard ) {

      // If this link has already been processed, skip it.
      if ( $userElement.hasClass( 'ffc-processed' ) ) {
        return;
      }

      // Set the "processed" flag to prevent loading the same link multiple times.
      $userElement.addClass( 'ffc-processed' );


      // Because the user link is in a nested entry.
      var $userLink = $userElement.find( 'a:first' );

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

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

      // Special case for these pages, to make it look nicer and fitting.
      if ( onHoverCard || Page.is( 'fff discover tree' ) ) {
        $userLinkSpan.before( '<br class="th-ffc-br" />' );
      }

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

      // Extract the userID from the url. Special case for discover page!
      var userID = ( Page.is( 'discover' ) ) ? $userElement.attr( 'user_id' ) : userUrl.split( '/' )[1];

      // Check if the current user has already been loaded.
      var userObject = Users.getUserObject( userID, true );

      // Add this span to the list that needs updating when completed.
      if ( userObject instanceof UserObject ) {

        // If this user has already finished loading, just update the span, else add it to the queue.
        if ( userObject.isFinished() ) {
          userObject.refresh( $userLinkSpan, 0 );
        } else {
          userObject.queueUserLinkSpan( $userLinkSpan );
        }

        return;
      }

      // Create a new UserObject and load it's data.
      userObject = new UserObject( userID, userName, userUrl );

      // Load the numbers from the user profile page.
      setTimeout( function() { $.get( userUrl, function( response ) {
        // Get rid of all images first, no need to load those, then find the links.
        var $numbers = $( response.replace( /<img[^>]*>/g, '' ) ).find( '.profile_details .numbers a' );

        // If the user doesn't exist, just remove the span.
        if ( 0 === $numbers.length ) {
          $userLinkSpan.remove();
          return;
        }

        // Set up the Friends link.
        var $friends = $numbers.eq( 0 );
        var friendsUrl = $friends.attr( 'href' );
        var friendsCount = $friends.find( 'span' ).text();
        var $friendsLink = $( '<a/>', {
          href: friendsUrl,
          html: friendsCount
        });

        // Set up the Followers link.
        var $followers = $numbers.eq( 1 );
        var followersUrl = $followers.attr( 'href' );
        var followersCount = $followers.find( 'span' ).text();
        var $followersLink = $( '<a/>', {
          href: followersUrl,
          html: followersCount
        });

        // Add titles to pages without posts and not on hover cards.
        if ( ! onHoverCard && ! Page.is( 'has-posts' ) ) {
          $friendsLink.attr( 'title', 'Friends' );
          $followersLink.attr( 'title', 'Followers' );
        }

        // Add the Friends and Followers details, then refresh all userlink spans.
        userObject.setFriendsInfo(   $friendsLink,   friendsUrl,   friendsCount   );
        userObject.setFollowersInfo( $followersLink, followersUrl, followersCount );
        userObject.refresh( $userLinkSpan, 0 );
      })
      .always(function() {
        // Make sure to set the user as finished loading.
        Users.finishedLoading( userID );
      }); }, 100 );
    },

    /**
     * Load the FF counts for a user's hover card.
     * @param  {jQuery} $userHoverCard Hover card selector.
     */
    loadUserHoverCard : function( $userHoverCard ) {
      if ( 'disabled' === settings.showFFC ) {
        return;
      }

      var t = this;
      // As long as the hover tooltip exists but the card inside it doesn't, loop and wait till it's loaded.
      if ( $( '.tooltipster-user-profile' ).length && $userHoverCard.length === 0 ) {
        setTimeout(function(){
          t.loadUserHoverCard( $( $userHoverCard.selector, $userHoverCard.context ) );
        }, 500);
        return;
      }

      doLog( 'Start loading Friends and Followers (Hover Card).', 'i' );

      FFC.loadUserLink( $userHoverCard, true );
    },

    /**
     * Load Friends and Followers
     * @param {boolean} clean Delete saved details and refetch all.
     */
    loadAll : function( clean ) {
      if ( 'disabled' === settings.showFFC || ( 'hovercard' === settings.showFFC && Page.is( 'has-posts' ) ) ) {
        return;
      }

      if ( clean ) {
        if ( confirm( 'Reload all Friend and Follower details of all users on the current page?' ) ) {
          doLog( '(clean) Start loading Friends and Followers.', 'i' );

          // Reset list.
          Users.userObjects = {};

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

      // Special case for "Discover Users", convert all the usernames to links.
      if ( Page.is( 'discover' ) ) {
        $( '#discover .user_card_1_wrapper' ).each(function() {
          var $convertToLinks = $( this ).find( '.tree_child_fullname, .tree_child_coverpicture, .tree_child_profile_image' );

          // Get the last part of the "Follow" button link, which is the numerical user ID.
          var userID = $( this ).find( '.follow_button' ).attr( 'href' ).split( '/' ).pop();

          if ( userID ) {
            // Make the username and profile images a link to the profile.
            $convertToLinks.each(function() {
              // Wrap each item in a link and add the user ID which we need for FFC.
              $( this ).attr( 'user_id', userID ).wrapInner( '<a href="/users/' + userID + '"></a>' );
            });
          }
        });
      }

      // Find all users and process them.
      var $newUserLinks = $( Observer.queryToLoadFF ).not( '.ffc-processed' );

      doLog( 'New user links found: ' + $newUserLinks.length );

      $newUserLinks.each(function() {
        var $userElement = $( this );

        // Is this link on a tooltip hover card?
        var onHoverCard = ( $userElement.closest( '.tooltipster-base' ).length !== 0 );

        FFC.loadUserLink( $userElement, onHoverCard );
      });
    }
  };

  /**
   * Manage all users for the Friends and Followers counting.
   */
  var Users = {

    userObjects : {},

    getUserObject : function( userID, setLoading ) {
      if ( Users.userObjects.hasOwnProperty( userID ) ) {
        doLog( '(' + userID + ':' + Users.userObjects[ userID ].userName + ') Already loaded.' );
        return Users.userObjects[ userID ];
      }

      if ( setLoading ) {
        doLog( '(id:' + userID + ') Set to loading.' );
        Users.userObjects[ userID ] = true;
      }

      doLog( '(id:' + userID + ') Not loaded yet.' );
      return false;
    },

    finishedLoading : function( userID ) {
      if ( Users.userObjects.hasOwnProperty( userID ) ) {
        Users.userObjects[ userID ].setFinished();
      }
    }
  };


  // Initialise after all variables are defined.
  Page.init();
  Observer.init();
  Posting.init();


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

  // Add the About (and Settings) window to the menu.
  addAboutWindow();


  // As the observer can't detect any changes on static pages, run functions now.
  FFC.loadAll();
  QM.load();
  emphasizeNestedRepliesParents();
  tweakMessagesPage();
  FFM.init();


  // Load Notifications Reloaded?
  if ( settings.notifReloaded > 0 ) {
    notifications.urls.notifications = '/notifications/request/?count=' + settings.notifReloaded;
    notifications.get().success(function() {
      $.event.trigger( 'notificationsRender' );
    });
  }

  // Add Notifications Reloaded to the notifications page.
  if ( Page.is( 'notifications' ) ) {

    var $ajaxNRRequest = null;
    var $notificationsList = $( '#new_notifications_list' );

    // Add the empty filter message
    var $emptyFilterDiv = $( '<div>No notifications match the selected filter. Coose a different one.</div>' );

    // Select input to filter kinds.
    var $kindSelect = $( '<select/>', {
      'id'  : 'th-nr-nk-select',
      title : 'Filter by the kind of notification'
    });

    // Select input to filter users.
    var $userSelect = $( '<select/>', {
      'id'  : 'th-nr-nu-select',
      title : 'Filter by user'
    });

    // List the available count options.
    var selectCount = '';
    [ 30, 50, 100, 200, 300, 400, 500 ].forEach(function( val ) {
      selectCount += '<option value="' + val + '">' + val + '</option>';
    });

    /**
     * Filter the current items according to the selected filters.
     * @param {string} which Either "kind" or "user".
     */
    function filterNotifications( which ) {
      var $notificationItems = $notificationsList.find( '.notifications_item' ).show();
      var kindVal = $kindSelect.val();
      var userVal = $userSelect.val();

      if ( undefined === which )
        updateSelectFilters( $notificationItems );

      // Filter kinds.
      if ( '' !== $kindSelect.val() ) {
        $notificationItems.not( '[data-kind="' + kindVal + '"]' ).hide();
      }

      // Filter users.
      if ( '' !== $userSelect.val() ) {
        $notificationItems.not( '[data-user-id="' + userVal + '"]' ).hide();
      }

      var $notificationItemsVisible = $notificationItems.filter( function() { return $( this ).find( '.new_notification:visible' ).length; } );

      if ( $notificationItemsVisible.length ) {
        $emptyFilterDiv.hide();
      } else {
        $emptyFilterDiv.show();
      }

      // The other select filter, not the current one.
      var other = ( 'kind' === which ) ? 'user' : 'kind';

      if ( '' === kindVal && '' === userVal ) {
        updateSelectFilters( $notificationItems, other );
      } else {
        updateSelectFilters( $notificationItemsVisible, which );
      }
    }

    /**
     * Update the entries and count values of the filter select fields.
     * @param  {jQuery} $notificationItems List of notification items to take into account.
     * @param  {string} which              The filter that is being set ("kind" or "user").
     */
    function updateSelectFilters( $notificationItems, which ) {
      // Remember the last selections.
      var lastKindSelected = $kindSelect.val();
      var lastUserSelected = $userSelect.val();

      // The available items to populate the select fields.
      var availableKinds = {};
      var availableUsers = {};

      $notificationItems.each(function() {
        var $notificationItem = $( this );

        // Remember all the available kinds and the number of occurrences.
        if ( availableKinds.hasOwnProperty( $notificationItem.attr( 'data-kind' ) ) ) {
          availableKinds[ $notificationItem.attr( 'data-kind' ) ]++;
        } else {
          availableKinds[ $notificationItem.attr( 'data-kind' ) ] = 1;
        }

        // Remember all the available users and the number of occurrences.
        if ( availableUsers.hasOwnProperty( $notificationItem.attr( 'data-user-id' ) ) ) {
          availableUsers[ $notificationItem.attr( 'data-user-id' ) ].count++;
        } else {
          availableUsers[ $notificationItem.attr( 'data-user-id' ) ] = {
            username  : $notificationItem.attr( 'data-user-name' ),
            count     : 1
          };
        }
      });

      // Update the kinds if the "User" filter has been changed.
      if ( undefined === which || 'user' === which || '' === lastKindSelected ) {
        // List the available kinds, adding the number of occurances.
        $kindSelect.removeAttr( 'disabled' ).html( '<option value="">All Kinds</option>' );
        for ( var key in availableKinds ) {
          if ( TSUConst.kindsTexts.hasOwnProperty( key ) && availableKinds.hasOwnProperty( key ) ) {
            $kindSelect.append( '<option value="' + key + '"' + selected( key, lastKindSelected ) + '>' + TSUConst.kindsTexts[ key ] + ' (' + availableKinds[ key ] + ')</option>' );
          }
        }
      }

      // Update the users if the "Kind" filter has been changed.
      if ( undefined === which || 'kind' === which || '' === lastUserSelected ) {
        // List the available users, adding the number of occurances.
        $userSelect.removeAttr( 'disabled' ).html( '<option value="">All Users - (' + Object.keys( availableUsers ).length + ' Users)</option>' );

        // Sort alphabetically.
        var availableUsersSorted = [];

        for ( var userID in availableUsers ) {
          availableUsersSorted.push( [ userID, availableUsers[ userID ].username.toLowerCase() ] );
        }
        availableUsersSorted.sort( function( a, b ) { return ( a[1] > b[1] ) ? 1 : ( ( b[1] > a[1] ) ? -1 : 0 ); } );

        availableUsersSorted.forEach(function( val ) {
          var userID = val[0];
          if ( availableUsers.hasOwnProperty( userID ) ) {
            $userSelect.append( '<option value="' + userID + '"' + selected( userID, lastUserSelected ) + '>' + availableUsers[ userID ].username + ' (' + availableUsers[ userID ].count + ')</option>' );
          }
        });
      }
    }

    /**
     * Refresh the selected number of notifications.
     */
    var reloadNotifications = function() {
      // If a request is already busy, cancel it and start the new one.
      if ( $ajaxNRRequest ) {
        $ajaxNRRequest.abort();
      }

      doLog( 'Loading ' + $countSelect.val() + ' notifications.', 'i' );

      // Show loader wheel.
      $notificationsList.html( '<img src="/assets/loader.gif" alt="Loading..." />' );

      // Disable select inputs.
      $kindSelect.attr( 'disabled', 'disabled' );
      $userSelect.attr( 'disabled', 'disabled' );

      // Request the selected amount of notifications.
      $ajaxNRRequest = $.getJSON( '/notifications/request/?count=' + $countSelect.val(), function( data ) {

        // Clear the loader wheel.
        $notificationsList.empty();

        // Make sure we have access to the notifications.
        if ( ! data.hasOwnProperty( 'notifications' ) ) {
          // Some error occured.
          $notificationsList.html( '<div>Error loading notifications, please try again later.</div>' );
          return;
        }

        // No notifications.
        if ( 0 === data.notifications.length ) {
          $notificationsList.html( '<div>You don\'t have any notifications.</div>');
          return;
        }

        // Append the notifications to the list. Function used is the one used by Tsu.
        $( data.notifications ).each(function( i, item ) {
          //var $notificationItem = $( window.notifications_fr._templates['new_comment_in_post'](
          var $notificationItem = $( window.notifications_fr._templates.new_comment_in_post(
            item.url,
            item.user,
            item.message,
            item.created_at_int
          ))
          .attr({
            'data-kind'      : item.kind,
            'data-user-id'   : item.user.id,
            'data-user-name' : item.user.first_name + ' ' + item.user.last_name
          });

          $notificationsList.append( $notificationItem );
        });

        // Add the empty filter message.
        $notificationsList.append( $emptyFilterDiv );

        // Filter the notifications to make sure that the previously selected filter gets reapplied.
        filterNotifications();
      })
      .fail(function() {
        $notificationsList.html( '<div>Error loading notifications, please try again later.</div>' );
      });
    };

    var $countSelect = $( '<select/>', {
      'id' : 'th-nc-select',
      html : selectCount
    })
    .change( reloadNotifications );

    $( '<div/>', { 'id'  : 'th-nr-div' } )
    .append( $kindSelect.change( function() { filterNotifications( 'kind' ); } ) )
    .append( $userSelect.change( function() { filterNotifications( 'user' ); } ) )
    .append( $( '<label/>', { html : 'Show:', title : 'How many notifications to show' } ).append( $countSelect ) )
    .append( $( '<i/>', { 'id' : 'th-nr-reload', title : 'Reload notifications' } ).click( reloadNotifications ) ) // Add reload button.
    .appendTo( $( '#new_notifications_wrapper' ) );

    // Reload all notifications and set data attributes.
    reloadNotifications();
  }

  /**
   * Convert timestamp to date and time, simplified date() function from PHP.
   * @param  {string}  format    Format of the date and time.
   * @param  {integer} timestamp UNIX timestamp.
   * @return {string}            The pretty date and time string.
   */
  function phpDate( format, timestamp ) {
    var d = new Date( timestamp * 1000 );

    var months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ];
    var year  = d.getFullYear();
    var month = d.getMonth();
    var day   = d.getDate();
    var hour  = d.getHours();
    var mins  = d.getMinutes();
    var secs  = d.getSeconds();

    // Check date() of PHP.
    var mapObj = {
      H : ( '0' + hour ).slice( -2 ),
      i : ( '0' + mins ).slice( -2 ),
      s : ( '0' + secs ).slice( -2 ),
      Y : d.getFullYear(),
      F : months[ month ],
      m : ( '0' + month ).slice( -2 ),
      d : ( '0' + day ).slice( -2 ),
      j : day
    };

    var re = new RegExp( Object.keys( mapObj ).join( '|' ), 'gi' );
    return format.replace( re, function( matched ){
      return mapObj[ matched ];
    });
  }


  /**
   * Add Post Archive to Analytics page to find previous post easier and add ALL the details!
   */
  if ( Page.is( 'analytics' ) ) {

    /**
     * Update the table zebra striping.
     * @param  {jQuery} $rowsVisible If the visible rows have already been found and passed, use those.
     */
    function paZebra( $rowsVisible ) {
      // If no visible rows have been passed, load them from the table.
      $rowsVisible = $rowsVisible || $paTableBody.find( 'tr:visible' );

      $rowsVisible.filter( ':even' ).addClass( 'th-pa-row-even' ).removeClass( 'th-pa-row-odd' );
      $rowsVisible.filter( ':odd' ).addClass( 'th-pa-row-odd' ).removeClass( 'th-pa-row-even' );
    }

    /**
     * Filter the posts by the selected types and update the table's zebra striping.
     */
    function paFilter() {
      $paFilterCheckboxes.each(function() {
        var $rows = $paTableBody.find( '.th-pa-pt-' + $( this ).attr( 'data-type' ) );
        if ( this.checked ) {
          $rows.show();
        } else {
          $rows.hide();
        }
        $( this ).siblings( 'span' ).html( '(' + $rows.length + ')' );
      });

      var $rowsVisible = $paTableBody.find( 'tr:visible' );
      if ( $rowsVisible.length > 0 ) {
        // Update the zebra striping of the table.
        paZebra( $rowsVisible );
        $paFilterEmpty.hide();
      } else {
        $paFilterEmpty.show();
      }
    }

    /**
     * Get all the posts before the passed post ID.
     * @param  {integer} before The last loaded post ID.
     */
    function paGetPosts( before ) {
      // Disable the filter checkboxes while loading.
      $paFilterCheckboxes.attr( 'disabled', 'disabled' );

      var url = '/api/v1/posts/list/' + window.current_user.id + '?_=' + Date.now() + ( ( undefined !== before ) ? '&before=' + before : '' );
      $.getJSON( url, function( data ) {
        if ( ! data.hasOwnProperty( 'data' ) ) {
          $paLoaderDiv.html( 'Error occured, please try again later.' );
          return false;
        }

        // We have our list of posts, extract all the info and add the rows to the table.
        data.data.forEach(function( post ) {
          // Remember the last post ID.
          $paLoadMorePosts.attr( 'data-before', post.id );

          // Find out what type of post this is.
          var postTypeText  = 'Personal Post';
          var postTypeClass = 'post';
          if ( post.is_share ) {
            postTypeText  = 'Shared Post';
            postTypeClass = 'share';
          } else if ( post.user_id != window.current_user.id ) {
            postTypeText  = 'Wall Post by ' + post.user.full_name;
            postTypeClass = 'wallpost';
          }

          // Put together the post links.
          var postLink = '/' + post.user.username + '/' + post.id
          var originalLink = '';
          if ( post.is_share ) {
            originalLink = '/' + post.original_user.username + '/' + post.shared_id;
          }


          var privacyIcon = '';
          var selectBox =
            '<ul class="privacy_' + post.id + ' black_dropdown_box" >' +
              '<li class="' + ( ( 1 === post.privacy ) ? 'checked_privacy_option' : '' ) + '"><img src="/assets/check_mark.png" height="16" width="16" alt="Check mark" style="display: ' + ( ( 1 === post.privacy ) ? 'inline' : 'none' ) + ';"><a href="/posts/change_privacy/' + post.id + '/1/' + ( post.is_share ? 'share' : 'normal' ) + '" data-method="patch" data-remote="true">only friends</a></li>' +
              '<li class="' + ( ( 0 === post.privacy ) ? 'checked_privacy_option' : '' ) + '"><img src="/assets/check_mark.png" height="16" width="16" alt="Check mark" style="display: ' + ( ( 0 === post.privacy ) ? 'inline' : 'none' ) + ';"><a href="/posts/change_privacy/' + post.id + '/0/' + ( post.is_share ? 'share' : 'normal' ) + '" data-method="patch" data-remote="true">public</a></li>' +
            '</ul>';

          // Depending on the post type, the privacy options are handled differently.
          if ( 'post' === postTypeClass ) {
            privacyIcon =
              '<a href="#" id="privacy_icon">' +
                ( ( 0 === post.privacy ) ? '<span title="Public" class="privacy_icon_public"></span>' : '' ) +
                ( ( 1 === post.privacy ) ? '<span title="Only Friends" class="privacy_icon_private"></span>' : '' ) +
              '</a>' + selectBox;
          } else if ( 'share' === postTypeClass ) {
            privacyIcon =
              '<a href="#" id="privacy_icon">' +
                ( ( 0 === post.privacy ) ? '<span title="Public" class="privacy_icon_public"></span>' : '' ) +
                ( ( 1 === post.privacy ) ? '<span><img alt="Only Friends" title="Only Friends" src="/assets/friends_icon.png" width="16" height="16" /></span>' : '' ) +
              '</a>' + selectBox;
          } else if ( 'wallpost' === postTypeClass ) {
            privacyIcon =
              '<a href="#" id="privacy_icon" title="Can only be changed in Settings &raquo; Privacy &raquo; Post on your Diary">' +
                ( ( 0 === post.privacy ) ? '<span class="privacy_icon_public"></span>' : '' ) +
                ( ( 1 === post.privacy ) ? '<span class="privacy_icon_private"></span>' : '' ) +
              '</a>'; // No select box, as this can't be changed per post. Only in Settings->Privacy for ALL wall posts.
          }


          // The post entry row for the table.
          var $tr = $(
            '<tr data-post-id="' + post.id + '" class="th-pa-pt-' + postTypeClass + '">' +
              '<td>' +
                ( ( post.has_picture ) ? '<a class="th-pa-picture" rel="posts-archive-gallery" href="' + post.picture_url + '"><img alt="' + post.picture_url.split( '/' ).pop() + '" src="' + post.picture_url + '" /></a>' : '' ) +
                '<div class="th-pa-post">' +
                  '<ul class="th-pa-meta">' +
                    '<li title="' + postTypeText + '"><i class="th-icon th-pa-pt"></i></li>' +
                    '<li class="th-pa-privacy privacy_box">' + privacyIcon + '</li>' +
                    '<li class="th-pa-date" data-date="' + post.created_at_int + '" title="' + phpDate( 'd. F Y - H:i:s', post.created_at_int ) + '">' + phpDate( 'd. F', post.created_at_int ) + '</li>' +
                    '<li class="th-pa-expand button th-pa-stealth" title="Expand text">+</li>' +
                    '<li class="th-pa-post-link th-pa-stealth"><a href="' + postLink + '" target="_blank" title="Open post">Open</a></li>' +
                    ( ( post.is_share ) ? '<li class="th-pa-original-link th-pa-stealth"><a href="' + originalLink + '" target="_blank" title="Open original post">Open original</a></li>' : '' ) +
                  '</ul>' +
                  ( ( '' !== post.title   && null !== post.title )   ? '<div class="th-pa-title th-pa-ellipsis">'   + post.title.trim()   + '</div>' : '' ) +
                  ( ( '' !== post.content && null !== post.content ) ? '<div class="th-pa-content th-pa-ellipsis">' + post.content.trim() + '</div>' : '' ) +
                '</div>' +
              '</td>' +
              '<td>' + post.view_count + '</td>' +
              '<td>' + post.like_count + '</td>' +
              '<td>' + post.comment_count + '</td>' +
              '<td>' + post.share_count + '</td>' +
            '</tr>'
          )
          .appendTo( $paTableBody );

          // Make the picture clickable to expand into a Fancybox.
          $tr.find( '.th-pa-picture' ).fancybox( { padding : 0 } );

          $tr.find( '.th-pa-expand' ).click(function() {
            // Are we expanding or extracting.
            if ( '+' === $( this ).text() ) {
              $tr.find( '.th-pa-title, .th-pa-content' ).removeClass( 'th-pa-ellipsis' );
              $( this ).text( '-' ).tooltipster( 'update', 'Collapse text' );
            } else {
              $tr.find( '.th-pa-title, .th-pa-content' ).addClass( 'th-pa-ellipsis' );
              $( this ).text( '+' ).tooltipster( 'update', 'Expand text' );
            }
          });
        });

        // Initialise or update the tablesorter.
        if ( undefined === before ) {
          $paTable
          .tablesorter({
            // First column by date, others by text.
            textExtraction : function( t ) {
              return ( 1 === $( t ).find( '.th-pa-date' ).length ) ? $( t ).find( '.th-pa-date' ).attr( 'data-date' ) : $( t ).text()
            }
          })
          .bind( 'sortEnd', function() {
            paZebra();
          });
        } else {
          $paTable.trigger( 'update' );
        }

        // Update zebra striping.
        paFilter();

        // Are there more posts?
        if ( data.data.length < 10 ) {
          $paLoaderDiv.html( 'No more posts to load.' );
        } else {
          $paLoaderWheel.hide();
          $paLoadMorePosts.show();
        }
      })
      .always(function() {
        // Enable the filter checkboxes.
        $paFilterCheckboxes.removeAttr( 'disabled' );
      });
    }


    // Posts Archive table.
    var $paTable = $( '<table/>', {
      'id'  : 'th-pa-table'
    });

    // Table header.
    var $paTableHeader = $( '<thead/>', {
      html :
        '<tr>' +
          '<th title="Sort by Date">Posts Archive (by Tsu Helper)</th>' +
          '<th title="Sort by Views"><span class="icon view_icon"></span></th>' +
          '<th title="Sort by Likes"><span class="icon like_icon"></span></th>' +
          '<th title="Sort by Comments"><span class="icon comment_icon"></span></th>' +
          '<th title="Sort by Shares"><span class="icon share_icon"></span></th>' +
        '</tr>'
    })
    .appendTo( $paTable );

    // Add a filter to choose which type of posts to display.
    var $paFilter = $( '<div/>', {
      'id' : 'th-pa-filter',
      html :
        'Filter Posts: ' +
        '<ul>' +
          '<li><label><input id="th-pa-cb-post" type="checkbox" checked="checked" data-type="post" />Personal Posts <span></span></label></li>' +
          '<li><label><input id="th-pa-cb-share" type="checkbox" checked="checked" data-type="share" />Shared Posts <span></span></label></li>' +
          '<li><label><input id="th-pa-cb-wallpost" type="checkbox" checked="checked" data-type="wallpost" />Wall Posts by other Users <span></span></label></li>' +
        '</ul>'
    });
    // Call the filter when a checkbox value gets changed.
    var $paFilterCheckboxes = $paFilter.find( 'input[type=checkbox]' ).attr( 'disabled', 'disabled' ).change( function() { paFilter(); } );

    // Message to display if no posts match the chosen filter.
    var $paFilterEmpty = $( '<div/>', {
      'id' : 'th-pa-filter-empty',
      html : 'No posts match the selected filter.'
    })
    .hide();

    // Table body.
    var $paTableBody = $( '<tbody/>' )
    .appendTo( $paTable );

    // The row of the table to display the loading wheel and the "Load More Posts" button.
    var $paLoaderDiv = $( '<div/>', { 'id' : 'th-pa-loader-div' } );

    // Show only the loader wheel to start with.
    var $paLoaderWheel   = $( '<span><img src="/assets/loader.gif" alt="Loading..." />Loading more posts...</span>' )
    .appendTo( $paLoaderDiv );

    // Button to "Load More Posts".
    var $paLoadMorePosts = $( '<span class="button" style="float:none;">Load More Posts</span>' )
    .hide()
    .click(function() {
      $paLoaderWheel.show();
      $paLoadMorePosts.hide();
      paGetPosts( $paLoadMorePosts.attr( 'data-before' ) );
    })
    .appendTo( $paLoaderDiv );

    // Table wrapper.
    $( '<div/>', {
      'id' : 'th-pa-wrapper',
      html : $paTable
    })
    .prepend( $paFilter )
    .append( $paFilterEmpty )
    .append( $paLoaderDiv )
    .insertBefore( $( '.dashboard_post_statistic' ) );

    // Get the first lot of posts.
    paGetPosts();
  }


  /**
   * Add a specific class to all nested reply parent elements to emphasize them.
   */
  function emphasizeNestedRepliesParents() {
    // Make sure the setting is enabled and we're on the right page.
    if ( ! settings.emphasizeNRP || ! Page.is( 'has-posts' ) ) {
      return;
    }

    doLog( 'Emphasizing Nested Replies Parents.', 'i' );

    $( '.post_comment .load_more_post_comment_replies' ).not( '.th-nrp' ).each(function(){
      if ( /\d+/.exec( $( this ).text() ) > 0 ) {
        $( this ).addClass( 'th-nrp' );
      }
    });
  }

  /**
   * Autofocus text input and add line breaks to messages.
   */
  function tweakMessagesPage() {
    // Make sure we're on the right page.
    if ( ! Page.is( 'messages' ) ) {
      return;
    }

    doLog( 'Tweaking messages page.', 'i' );

    // Focus the recipient field if this is a new message.
    if ( document.URL.endsWith( '/new' ) ) {
      $( '.new_message #message_to_textarea' ).focus();
    } else {
      $( '.new_message #message_body' ).focus();
    }

    // Add line breaks to all messages.
    $( '.messages_content .message_box' ).each(function(){
      if ( ! $( this ).hasClass( 'tsu-helper-tweaked' ) ) {
        var $text = $( this ).find( '.message-text' );
        $text.html( $text.html().trim().replace( /(?:\r\n|\r|\n)/g, '<br />' ) );
        $( this ).addClass( 'tsu-helper-tweaked' );
      }
    });
  }

  /**
   * 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 ( ! publicDebug && 104738 !== window.current_user.id ) {
      return;
    }

    var logLevels = { l : 0, i : 1, w : 2, e : 3 };

    // Default to "log" if nothing is provided.
    level = level || 'l';

    if ( 'disabled' !== settings.debugLevel && logLevels[ settings.debugLevel ] <= logLevels[ level ] ) {
      switch( level ) {
        case 'l' : console.log(   logMessage ); break;
        case 'i' : console.info(  logMessage ); break;
        case 'w' : console.warn(  logMessage ); break;
        case 'e' : console.error( logMessage ); break;
      }
      if ( alsoAlert ) {
        alert( logMessage );
      }
    }
  }

  /**
   * Add the required CSS rules.
   */
  function addCSS() {
    doLog( 'Added CSS.', 'i' );

    // Remember to take care of setting-specific CSS!
    var settingSpecificCSS = '';

    // Hide Ads.
    if ( settings.hideAds ) {
      settingSpecificCSS +=
      '.homepage_advertisement, .rectangle_advertisement, .skyscraper_advertisement { position: absolute !important; left: -999999999px !important; }';
    }

    // Nested replies parents.
    if ( settings.emphasizeNRP ) {
      settingSpecificCSS +=
      '.th-nrp { text-decoration: underline; color: #777 !important; }';
    }

    // Quick Mention links for comments.
    if ( settings.quickMention ) {
      settingSpecificCSS +=
      '.th-qm-comment, .th-qm-reply { z-index: 1; font-weight: bold; font-size: 0.8em; display: block; position: absolute; background: #1abc9c; color: #fff; border-radius: 3px; padding: 2px; }' +
      '.th-qm-comment { margin-left: 11px; }' +
      '.th-qm-active-input { border-color: rgba(0,0,0,.4) !important; }' +
      '.post_comment { position: relative; }';
    }

    // Friends and Followers
    if ( 'disabled' !== settings.showFFC ) {
      settingSpecificCSS +=
      '.th-ffc-span .th-ffc-loader-wheel { margin-left: 5px; height: 12px; }' +
      '.th-ffc-span a { font-size: smaller; margin-left: 5px; border-radius: 3px; background-color: #1abc9c; color: #fff !important; padding: 1px 3px; font-weight: bold; }' +
      '.user_card_1_wrapper .tree_child_fullname { height: 32px !important; }';
    }

    // Add the styles to the head.
    $( '<style>' ).html(
      settingSpecificCSS +

      // Menu item.
      '#th-menuitem-about a:before { display: none !important; }' +
      '#th-menuitem-about a { background-color: #1ea588; color: #fff !important; width: 100% !important; padding: 8px !important; box-sizing: border-box; text-align: center; }' +
      '#th-menuitem-about a:hover { background-color: #1ea588 !important; }' +

      // FFM.
      '#th-ffm-unfollow-friends{ background: #dc3a50; color: #fff; font-size: .8em; }' +
      '#th-ffm-total-fffs { background: #090; color: #fff; margin-left: 8px !important; }' +
      '#th-ffm-link-start, #th-ffm-link-cancel { float: right; padding: 2px; }' +
      '#th-ffm-link-cancel { background: url(/assets/loader.gif) no-repeat; padding-left: 24px; }' +
      '#th-ffm-status-text { float: right; margin-right: 8px; }' +
      '#th-ffm-status-text label, #th-ffm-total-fffs, #th-ffm-unfollow-friends { padding: 2px 5px; border-radius: 3px; border: 1px solid rgba(0,0,0,.5); margin: -1px; }' +
      '#th-ffm-status-text input { margin: 0 5px 0 2px; }' +
      '#th-ffm-status-text * { display: inline-block; cursor: pointer; }' +
      '.th-ffm-card-received { background: #cfc; }' +
      '.th-ffm-card-sent { background: #eef; }' +
      '.th-ffm-card-fandf { background: #ffc; }' +

      // About & Settings windows.
      '#th-aw,    #th-sw    { width: 400px; height: auto; }' +
      '#th-aw *,  #th-sw *  { box-sizing: border-box; }' +
      '#th-aw h1, #th-sw h1 { margin: 5px; }' +
      '.th-buttons-div { padding: 5px; text-align: center; display: inline-block; border: 1px solid #d7d8d9; width: 100%; line-height: 23px; background-color: #fff; border-radius: 2px; margin-top: 10px; }' +

      // About window.
      '.th-update { background-color: #f1b054 !important; color: #fff !important; }' +
      '#th-aw > div { display: block; margin: 5px 0; }' +
      '#th-aw .card { padding: 5px; min-width: 100%; border: 1px solid #d7d8d9; border-top-left-radius: 30px; border-bottom-left-radius: 30px; }' +
      '#th-aw .card .button { width: 123px; }' +
      '#th-aw-update-button { margin: 5px; }' +
      '#th-aw-settings-button { float: right; height: 32px; width: 32px; background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAJBklEQVRYR5VXe2xbZxU/175+v+JnHMexY+fJwqpKU5HYgCEeg05oG4KBVvFHK7G0K9vatUmbPra6SdemlddHnFJUsTGJvwBpMBVNCMEQBSRUZUBXytq84zjxM7Hjt69fnO8+4tskm9QrXd/X953zO+f7nd/5TMFDHj6fj5abTSVXu0vSmEpBajUJ2Ux6/9DLr157GJPUwwwmY0/4/W67xTzz/K4XpOK5kVAIPvzzh78+9GL/Dx/G5qcCOBsIPCOjpS/korE9GHVRMDp88cKXm5sdf+np6pRCHUACFNSoOmQyGbg/M3tvoH/v58QAfG+99bhGoxzKReLfRzvMRnBbAhgdv/KsQqn+ravdTc1OTyfW1jJfOXP06Cc+v99C05KzTmfbj93ONpyLCBAAIIBqpQa3/vURU2bKP1q6f/93169fL48GAkNyhfxMq8spDS+Fp1OLob6NIDYBwMifU6lV7337m9+givkCFGs1+Mffblbq9epHFCV9rKunl9YqleizzrknFngcIKVhORKuLy8tlaBWnzSaTI9+8YnHqTLamVsMQnAhuAnEAwDOXQt8TylX/eZb6DydTEEul8McS8BstUCuUIRWhx1WojFI4jdhIvHNHRRI8CUZq1BrIJ5IgF6L10iU/dqk10MwGiYgZmb+fbsvEAiUuFn8QdIrU8hj33n6aSqfSUM2lxeFRnBIoFar8lOEkMVDhHd4rSMYKXIDsyeuFL1WC3enpmBlJTE2uPelAw8AIA/j7777Dkaxu8PrpVhoaIjLL+e3Xq1BJptFwmWBwoESzL+ElkKFKUOlWgWVUgVarRrUKjU7ZeMS5fJ5mJyczKXSWfe548dXNgEgbs4Fxv7abLN9yWFvoYh/IUWpVAqWlpfBbreD2WQSRdbIAgERiUQgjRXR6fWAXK5Yp0cVs3H3f3fL+Xzxa76Bgb8LBjaR8Mj58zqtXLa6fdvnaVoqYxGsrq5CIrEKXm870Ei0xsLxZoRMsdYoyCGAhaUQdHo4EISosXiCBPCH468e2ClGvwnAG37/TqvFfMPjdqPQ1KCQL0IQRaarswN5wGsPVgDrCi1zK1/DKxFGsuaoDPg9m89BaGkZurs6cZwE6sifO5/cK8aZWdOlQ5cKn5oB//Wf3e/q7OqW1DkCTc3MgdPRAiqVkiMCuhLohjUPJYZhvxEucAvPxUSgRbBipFIpWCxmdmY4Fq+lM+mDA/37AusARi5fPCyXy/ZWa/Xmeq2u0mg0dLvLRZEoCoUCxBIr4Ha1oWF0jMSjanVgKmUIBherhXI5jgsyX6lU+oxGo9bhcGCsPA68QXswPTsL3R2drFgV8iWYDwWxmKoVrKoMYlygLr/z9qS3w9tls9owxRSsYY1jU2HjjEbjyGgV6PU6jmk8K+9PTVXypWL/8KHBXxB3/f39Mtcjj/zKZDJ+197czFGELBOCWAwtQ5uzlUsOnmaLlc2YVCaHj2//B6jRq2MTDrvjMY2aKx1WTYh7XIFFXPsWNCiTIRn5I4+lhJz474kDBx9df4k3u30+pdegT/X19irE7xNIYIvJzLsn9vHk5SG0vFyjzo5dueWw23doNNrGIL78w9Eo2G02tuaFok6mkhCLRn9+8rXDL4odkfszly/FOjweKy3DheFFANcc9Do9lxFyiCSUBfAmAsAoEQDJAK8/fMIyqIZazAxhu2CgUCzC/OLipO/Q4R4xgP0+n9ai1yX6urv5DHAOc2hDg9K87px9jT9oMxRGAMOXL92yWSw79Dod60ikfUDEg0iwUGzkmwRZPTM3V83nc0MjR4b8xFy/z6d26HS/t1nMXzUam1grQo8olkqgVHCYxLaJTJPGRfn8F4aqQO2HatWKziRqtYrGdishVSBTKtj1z2dzmIBG28ExMDU9XctkcxmpRBJBY25HS4sCK4GqVCuE8BxfyRQMoM72BArKZQYw7RWGYWpor4QluvCAEL3i8+n1SsU9T3t7i0IuRz5KwNnuhlAwCDXsA1ibGBk3pcmgB4+zDfcBVUggL+IrK1DG8lwPHcepNEpUQiWkk0n2NRKyHo0nxlbn5gbJfkFMifXlHBwZOWA1Gy82N9tJJUOT0QRqjQaWFxc5LgjHhobIGmMj5z4QAWpzI/iFICa3wmvCTDFXznRfPPnmomBmkxS/cuyYFVO83NPTQ5NuR4629nZgcC2j4TD7TLJAssE/NNLN0QukKNkuTzsk4jHIpjPsMNKgIrHoxNmh4zsaUWxuK3Bi9Nx7pqam53A309ATvHN7vWxU83OzmPYKRyk2WBI2uXLPaqwmNzahcHgJd8opHmMdarhU88EgU2DyX7j4xsjtLTNwdOT0Sxab7WpnRwe1ihLcoB033Iqa4Ma0prMZNrp8rsCCkSNfNFhFVouFbUvzKL8F3IZtPHRNBrjz8Z3MQize8ku/H7dbogwc9vkstFQSfeLJJyVki82Wkjg6NlhMPhLTaDYCAmU3IKRKmBIDa+k0ttwoFB6oGGGpuOwQezkElkyuXhkeOHLwAQDk4cJPx0+nUmvHnW1OxMJ3N0wtqVlsIEDTqHDiWRtTJKp0UhGshAtjEAPZ1OC5oGAq23F3zK7PJhIOjpz+iYyWXW51OGgiOiUkXzgSKaMoMTrc4DcZDFI5CotYVAQjWNyofFlYS60VCwxTxz2g0mq1YvFgk0Pnq8nkXCEW344b0vSWHBBeHh4+tU8hlQV0BgONu6F0qcTsDE9OTrT29u6Sy+gRg9HoRKJywfHlSIRqFhUSteNGuVz2L92790/Ptm3XMJV7NFotnVpbmytucL5lBgQQR4aH9+HOdtdaJvfstdHRpPD+tVMnnzLoDB9YrbbGXzNMAaobRMKRufOvv+4Vk2/g1KkfULRksFhNfD3ga0T+mRnYRF/Ri5ePHes16HW3WxwOuRA+ib5QyOPeMXlj9MTJZz5r/sZvGzlAoiIdm1wJ44STbAjISXs8Ht3ze3ZPtLlctEAwwu4VLNvQ/MKFt8fHySaFCAWRWvFVuCd/LkhzIFd+A8c5JWAEx+QqnBgpkH04aWkEhHz0auDmVlHe/OOfDn3w/vsTvHHyz4c4IWzP8vfkmZwEDAvg/5gaEzsDHsw6AAAAAElFTkSuQmCC"); }' +
      '.th-aw-donate-buttons { margin: inherit; border-top-left-radius: 20px; border-bottom-left-radius: 20px; }' +
      '.th-aw-donate-paypal { float: left; }' +
      '.th-aw-donate-paypal img { vertical-align: middle; }' +
      '.th-aw-get-in-touch li { display: inline-block; margin-right: 10px; }' +
      '.th-aw-info li { margin: 2px 0; }' +

      // 16px icons.
      '.th-icon        { display: inline-block; width: 16px; height: 16px; vertical-align: text-bottom; }' +
      '.th-icon-bug    { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAADXklEQVQ4T02TYUwTZxjHn3vp3bXXu94hWNIW1EApU6ajshTciHFhumGMLlmyZNvnZXYmW/aFZPswY7JkGOMWE7fol31a4ofFZOyDzkkii5kEBKyEEsEKG2thBUvbu9L2rtf39lyRhUt+uefu3t//fe+e9xjYcQwEg7y1T/nCMulPd0anE9uP+vtfCbEM+wFdMy79PjOzudNhdlzYNTna+3KIE9jr1Kx2UAu8hJDndQTmTcP4+N6fc09wDEWsba8W0NW1T4nF/jKwdEa6O74WBGdUcLqAkDqoVk0olktQLBUvP5xKfINjyj09wbrx8YRqu3YA0/9G10W9bO71mLnVAwp8NlVsBL/fC7JHgkw2D+paGiJiRp9YZ65qnOJz8Y7S3dHHH9krsQNYWZabj7RKnw+9xkaVYITcHPmDbChtQKoOMEkFAloCTp88RTdm79HBscqP8XT5cir1fAndih0gIIHrx91fDQycfM97+ASXXYrTzMIk+Xd2ERqDXvCH36RSSztZnx4xbt2+9fPZkc0L6KSQoh3gQVp/OyPdePXEOyFOFAkR6oEofliciEFrTxhoNgm0mANDK9DJu78svD2svY/OIqIyx/oODhVK+utDe9OR8JE+ztLSKPuA8CIsJ4uwp0UAWi4AVdeBiI0QG7tvDC41TYhu5+zo/ZmovYJdSPvoGfl2Z3dXvZmKA3F5gEgNsFrwgE9UcfY80M0sOPz7IT71OHtsOD+AzlNkww6QkbZf35JvHu7u2MOsLRCg1VqbUzkXBJTSVsuxpZY3RKen5pdP38m/i3eeIXk7wC0IbOh82HPjVDjQLpdTxKqUa05ac0GTtBXAsE7I8QE6HFtd+vaJ/mEmo87i7U07gOvr7fxud0U9dLYFIvt9FmcVMjUpU3RCg7AVZrl3w1yyYnyfcjzQnJ7lxN/x6MrKVhdIm39X4NnKhvxDr3It5BN7mnjNwYMOmsGDyOlgAA9pXTLnVwpj58Zz0ebmhlwymVlFl9Z2IsIiyiHZcfCTTulKk4d7iScmcQDDUIZapSpL1/KVhWtz6qeP8uYMjs0h9tavydtnHgsZk/wXuuUvfS7HUWcdU1+1LH29VJ08H8sNqhX454Ws22+1M2C75rBwI/bmkuzvg5iIhuSRwouZ//8b/wNDgFsg+yoSRgAAAABJRU5ErkJggg=="); }' +
      '.th-icon-idea   { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACv0lEQVQ4T5WSXUiTURzGn/O++zB1HxTOqav8apZfjBgFflyotewmKBQhL7qxxFAIKiiSopI+vEkwpGTQTXpT0mWUmFRqorlEK/Nzqb3TbeKcujm3ve/bUVJc2kUHDgfO8z+////hPAQ7LNv9Q6fBkgtUOvJH7gEvNsZeH3r1dznZejFdo48jEvaGMru8ItxQDEYeCdG/DH6Rw8pIOzw9zU95/8rdvdUj3Ma7EMBkbcaTqKK68rD4LKozEIVVCnBB9DpBFjgE/AIcr++Z91/uPb8NMH7nwFmlsbhJfawSLKsCYcIhBJfABxwQvBzI/DgYhxVurwTu7uelSTdHm9cgmxMM30r8GHvuYY5cawDDRlJAGJ1gBbzfDt47CXH+B1j7JALxx2F7dqUj5fZEbgjgW3XCkq6sMlKmTAdhlRQgX7cgBJzgPT8hzn0HO2tFMO0Efpnrl9NqrIoQwMC1fQsxpSaVRK6AJEIPQtYAPgircxCXp6kFK90cVvU5mGl64858MKUOAViuxrVpC435TMQEpLIkCqAWRB7wuUCW7CBuJwJ7whEU0zH7tq/1cC1nCgF0X9IWRcRoXmiyA2A8LrDBMICngKAPxO+HIGMQ1Gjh6JTCO+s8c/TRzHomQr6xqyq6fndGdKU6eQbSOc+mKEgJgqpwLIzFYH7Q/jir3l61Yw7aL0ZFSlhSG21QV6h0Ntqd5mCthVQGj1MHW5erOSiI5XkNzuUdAWuX78tUCRKVYkKTuYvmYHG9jmEj4Pzqx6rXk5vX4OrYmt4QCxtCXV2dmKhPRZbRCOvoELr6+jE48AVms3lb/T8BBfkF0GgUmJ1zo/NDByyW/wTkZOfiYKoew6Nj+NzxCb39lp0naG1tVfE8n0IIMdCdQm0k63S6Uy0vW9DW9g4+nxcnCwsXi0tKlBzHNVJ9UBTFKXpOmUym/t+tniogNDUKLAAAAABJRU5ErkJggg=="); }' +
      '.th-icon-heart  { margin-right: 5px; background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACrUlEQVQ4T31T3UsUURQ/987dnR33Y9x1LT92TQSL1upFKQxLrIU1QpKioggKFKG3/o1eo6dQiHrrQUppQVAqoowFMwtiSXzR3dZsPxx3nZ11Zu7tztj6QdKBC+fj/n73d87hIuD2dVjuFIANc/e6FTOAWcrY2Kmx4ku7PuQdxAgNcbcLE6ebmltJxNBox6jyBM3f87U7JNfnw903RU/zMQcwBlph1cx/m65sppOPeSh4w5H7/hMXRFGux1gQkK6WaPrts3Ill7qFvtyVHx46M/BAdnucZn4FgJqAPUFA9W2wOj9dZoxCU2dMomtLQEtZXjcA+xqgIspG6s3zOJq740u29V47CqkFZKz/4hcoILEGhEAYWF0LtVqA3DKmnJxVVLtBweMH0toJix9eF1Hiti/b3nWuzlxeAF21LmwbctZwJQHbp6U8sK3dGhFFIKEOWFxI5NCnG77Z1uMdp9HvJVzZrOwQAOIuJtsxl21Ntmqi2wWG/4i58iM5g94PyiPBUPOjAFJENa+ASffc3MXseIKAoSZQC1ndzWf9cxi9ABAar/gLjaE6SdRyRN0oW2M40DAGcPskKItBPZNae3p+QhmxhML05do+gvGrpga3JG4ppLiu8W3uV4IQAq+fgx2yvpopaqZuRi9ObSRsApskKkeRQxhvapQkl64QJbdLgjACOeCCMvHrmUxJY7oRi84UZ+1h79Ua7/P0Cg7nZMgiMQpkPavZ5dqgBQ7o6fSmRpkRu/QX/A+BlZjgJAQck+FmSZLMgr0GlYNTKVUzqREbeLf9ctX2KagmJ3u8PQiTeLjF47JyK8tctsHBH/eDD1RQJRk/6+0mmExZscFfvnoAeIcgHo9HBEGI8EnbhxdO8hMB/g9sQ3x/AN/5ZhK8PmeaZqK/vz/xXwV7+/yf/wc6MhxIaZWcBAAAAABJRU5ErkJggg=="); }' +
      '.th-icon-heartp { margin-right: 5px; background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAC0klEQVQ4T5VTXUiTURh+zr5vP7rNDV2KTl1Oc6YY9IOQqIVZLAjBKAUvBOmmQBCli+gmKKgMsqTAq7ow8mJEhMHM0goTMkkwIRn+DqeRuuXPdH/fX+f7NMG0iw4czvue532e94dzCP5ztboQ11yN8B8akY3W/oQialyRCKlVAAkD1G9vKlt9IbsP+k0XJIoTkCKGURt4gRsDxPbmsrXHpOW90aHV6D15GRWwmLIVdjC0CO/CIAJrs20EEpOUkNVgSymCIc4CFWER5YIYnemicUvnyb2+pIeOzJONZkMqwpyf0kVoGCPitYnwzH6AJEk4mHkKoVgAMSFIfRFa1gyBFzAy3fWO3O1NnjvmqLKux+ZpEBWQBDAqLeLV+6BVG5WOIjRjmFuCIEblAqHTmGGOy8bw+CuQ2z1p0SOOM5qV8DRCkTWlBYBA5DV4+TSgCNRctmyStzCdVg+zzo6Rib4YueVOHy2wHy8M8z+xHqIlQlBIXAzofh5S7Mp6/RZZlmZg1BuhJhZ4vF8HyI3XtgarZf8jk0mLwIofdMJ407lJ/HudrY0Hy6phMSfDv7yOBf9cPbnoAlOgzeHtGbl03kEEfgXR6wruKVBRY4QlMQFiLB7TvolnN6um6pR3cN114DTLMm/tmTZIqiAW/RuIRngMdkcUoZLKODAMg5RkA0TOgBmfFzwvFN+pnvysCMjrmsvhZAjbbbdlgLCr8M1u4EvPhoIVn9MjLVUPiUvA1KwPgsSXtlSPD8jYtoDsXO0sKFermL6cLCttZxlzc5sVpKfrAN6MSe88JFEsban9rpB3CSgiHYfKVayqL9dupZWsK0Eip8fEzA/6eKTS+3Xftsl7CsiXTR2HTzCEfMzLsSoCnkmaWRJ2kf8pIAONT46WMGrySbYFTiptuzS8I/OOFtxudz6dcj4hRNkULKQ7X/4Xm1lU8jFG/8UQxYcFQRhyOp1D8uVvoY8fXsjKUkIAAAAASUVORK5CYII="); }' +
      '.th-icon-help   { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACtklEQVQ4T6WTTUhUURTH//dd33uOM8/GQafRZhaSNTmmLWqRIEI7i5blIgqiyBoNkgSjohiK6DuMaoYEKQg3btqYLV0UUUILMy2pheH356i9+XjvzZvbnTcqfiw7cLlwDud3/ufccwn+08jm/FDXoJQvyA0gOAaGagJCGWEfwcjb5XQyEqqv0NfnbAA86vrdQKnwsqBQgV2xQZRlEEKgJ5KIqwkszP1F2mSNLfU7I6uQNcCDrl9BWRbDxT439JQA3WDg2VwEvxiDJBJIOWlMjc4gqRmNrfW7LIgFCIUGpbyArPlKi7EcI0hz3353FB6HaRVaSAgYnFegGiKUPIbxkUnEZjQl1FShWoC7ncMXXW7nM4gKNMNEymQ47NfwZVTCPAceDRjQdR29Iw7IIgVJqZidWWy9fmL3Qwtwu3P4Q7HXU6PySinTBFe8wfZ5uSKfiVefKXKoAMVOMPFnYuDGSX9VtoU3PzWP1yepMd2qvtmO7GUQBAHd3xgoJXDkiZgeH9NDp/bIFuDm6x9aUYmXA7QN1SXKUFumw+20obs/jeWkNVcOkDE7OabfOl2eBVzrGPpUuN1THU/y6iw7uIwdLDVQ4pLwrp9BS604iQC7jWJuemrgzpnybAtX2ocubXPltzFBQUpPrAFq/YRLpugdWs0GqGgDZSoWo4tX75+ruGcBjvNnLN1BtIJCDwy+A6Ye3zKHjEPgybLIEJ2fRtSMOdvPH1haW6TLkYGgKIphh7OIz4FyJUnUlBlQcoH330VeORdUMKEuzcEw9ObHFyqfZqAbVrn5+UCQUCFsdygQJTuP0qwSPhdDjyEeU5FOp1raGquerErc8pkaX/Q6KHMF+R84ywn+FcIwY6zDJAuRcNMhdX1/FqCnpyfAhxXgSdbhrkp+Mvd6G+KQPh7/appmX11dXV8m+A+i8hIg7mH0KgAAAABJRU5ErkJggg=="); }' +

      '.th-icon-manual { margin-right: 10px; float: left; display: inline-block; width: 32px; height: 32px; vertical-align: text-bottom; background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAE/klEQVRYR8WVW0wcVRjH/3vjtjt7mdlFSmkLLtJYWKTF2rpgwXJRY6oxaLVYbR80KYmJocYaTRNjo1Fqi4t9wAcSo9b65qu+GDVqSKppK1qUgLSlQOkChYVlgb36nZm9zHLbYW3sSXYzmXPmfL/z//7fd1S4w0PF4peWluYZzcZXBYvwIC8IhmP1lsrbwcWXVCF/1zNijNWGOOlwOHo4jnPwvGWM54W+prs9NbcDwG4vQtkLrtQA5eXlEYNB72lrO2myWq0wGAziLzs7G5mZmWmzXD7bqhxAb9APdLg6ikmJOwPATtzcfABVVdWIqfC/KsAAeIGHhbeG3qg3a9LWfcmH6/AAB0GwwGTNxZlDpUAk8t8YVCoMDw3B8WKHMhNyHClg4UUA18ESREKhOMAyFPHF2oBqjQajozdQfuhjZQBiCngBZpsN7c/aEQoGVwwixV4jOM2xWa1WhzG3G/cdPqMQgNwv8FIKTu8vigIkshBZISh7FwqFxUXhpPkINNoM3CSAnS93KgPgDBwsZEKzYMOp/YUIBgJJHpDOTP/Rw7N4/kAQs75FzC0sIhAMkzAJZTRaLSYmp9B0/JwygFgVmEmBD5u2ICACyKSOP0oPwXAEUzM+3LDWIrPImRQ8Ri6mQpc1m+xmVVfdvaajsXcindQJmQckE55s2oSA3y/7LnbsBFKQpB8Z98C/5wQcdtuSFKxcQN1/TkCrUQdzcnRdjaXmFrYqAUBVIJAJTZSCD54qIIBFSXSK/culgWWGDBDA+PQcHEc+Q0lRLvyh1GXb238LOVlaqNTwUcZe2VchfBoH4KKNyCjk4v0n8+FfXJA5UHqU/C2NQDCEoTEPwvXvoXCzDQu0Y6oxeGVaXJKVoYn4g+GwPkcn1ShLAbsDxD5AZfjuvjz4FyQAFrK7Z3DZ3kEK6CYFyo58jmJSYD0Asc1y9LrZ5BSIALk48XguFufn40ET5pYpECAF3B5E6ttgVwgg5pwiqukvW6tC//UZGQCVIbsLTFYb3nmMJPX5kk8dbTDne6+J75kJx6d8pMAXuMd+l6jAd2c7U2VBnK872IIsrRr/XPckAFgfYFVgJIC3H+ExLweQ1XdMA1b3w+5pRBpPoaQ4T1EK5HQMYODatFwB6TZkfeB4gwU+rzexfiUASsHwxAzQeBpbSzaIAN8rVODhqAIDV1YBeKvOhLkYwJIWHFeAAEYIINLYjm1b89NSoG9wSp4CA7Vi6gOUgjdrDfAygFUuHQbBynBknJpc40co27YR86TAD18q80Dt8y1kQjX+6r+V7AELecBMAMdq9PDO0uZrAYidkKXABUdZgQiwnsEAevsmk6tAIA8YqRO+vicb3hnanPWBVSBYJxydmEW4oQMV5ZswHwjjx3PKFKhpJgV0avT8PSFTgDUidh1TJ3ytOhOzHglgtREDCBHA9orNIsB6BgP4vXc82QOsCoxUBUedDMCjAMCLUIMLO7ZvEQF+UqjAQ1EFLl12xwEuUh+oiPWBVmdGSgDxNpwkgDoXKisL01Lgwh834wBPGDjua8HCa4x0F7Tu1mEmhQIxgOBeF3beXwQfKfDzV8o8UH2gBTmUgt96xiQANpxO515S4DCXt+Hp5wrc2anyyQDGqBVrHu3E7gckgPUMBvDrRRmA7ONcejbQT69kw0++vdq9a8dGfToA5y+MJhRQEmylNZ3fXG0nGV9K53tqaF3/Aot1QCKE5DaSAAAAAElFTkSuQmCC"); }' +

      // Heartbeat.
      '.th-about-love:hover .th-icon-heart { -webkit-animation: heartbeat 1s linear infinite; -moz-animation: heartbeat 1s linear infinite; animation: heartbeat 1s linear infinite; }' +
      '@keyframes "heartbeat" { 0% { -webkit-transform: scale(1); -moz-transform: scale(1); -o-transform: scale(1); transform: scale(1); } 80% { -webkit-transform: scale(0.9); -moz-transform: scale(0.9); -o-transform: scale(0.9); transform: scale(0.9); } 100% { -webkit-transform: scale(1); -moz-transform: scale(1); -o-transform: scale(1); transform: scale(1); } }' +
      '@-webkit-keyframes "heartbeat" { 0% { -webkit-transform: scale(1); transform: scale(1); } 80% { -webkit-transform: scale(0.9); transform: scale(0.9); } 100% { -webkit-transform: scale(1); transform: scale(1); } }' +
      '@-moz-keyframes "heartbeat" { 0% { -moz-transform: scale(1); transform: scale(1); } 80% { -moz-transform: scale(0.9); transform: scale(0.9); } 100% { -moz-transform: scale(1); transform: scale(1); } }' +
      '@-o-keyframes "heartbeat" { 0% { -o-transform: scale(1); transform: scale(1); } 80% { -o-transform: scale(0.9); transform: scale(0.9); } 100% { -o-transform: scale(1); transform: scale(1); } }' +

      // Settings window.
      '#th-sw label, #th-sw input, #th-sw select { display: inline-block; cursor: pointer; }' +
      '#th-sw form > div { margin: 5px 0; }' +
      '#th-sw-back-button { float: left !important; }' +
      '.th-sw-help { margin-left: 4px; cursor: help; }' +

      // Show custom number of notifications.
      '#new_notifications_wrapper { position: relative; }' +
      '#th-nr-div { position: absolute; top: 0; right: 15px; }' +
      '#th-nr-div select { cursor: pointer; margin: 0 5px; }' +
      '#th-nr-nk-select, #th-nr-nu-select { width: 80px; }' +
      '#th-nr-reload { display: inline-block; height: 16px; width: 16px; vertical-align: text-bottom; cursor: pointer; margin: 0 5px; background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAC2klEQVQ4T32SS08TURiG37l0ept2aGnxAqIoprXVyNqNW1YaF3YhwcR4SVRYiFFJRDHES8UYjBeMMUYj0RhIZCXxFxhDwBAUsKlKJAJFaellpu1MZ+Y4rbEBL5zdOfnOc773Ow+FVVa4/7PA0aQ7rcunL4WC4r9KqdUAV/ujXgfHflcU9ZMKvelcyDf8Z/1fgOv9H30U2HYKZI/JxLq9HgfsVhbRL4uaDj08grHOgVBI+w1aAbj+ItICir7l2+JhPG4ekgLE0xoYGqiwU4gvJjEzlxouKPrBC83+SBFSBlx9Fj2+scbZ6650QNEZSHkdOYUgJ5PSY7yVRoWNILWUwdx8avDd4Nj+gYGQVgJ09n2oNzGmiYC/mhNlBpmsiqwoQpVlsBY7GLMVRM1Dl7OQ87nOjgO+rhURuvoid2pr3C0aa0daVCDGFzRdJ2GKkFeM2fKG4SxQsqmETtB0qdn/evkgf3XwNBLdULuuPpOnkBMzyGfTHV0H/VfOPRgRbDYhSXRtVFWw//JR//Q/f+HikynZW13DFTNL8RiyWmHdjUPBWBFg5vibmaVEa0/brtx/Peh4PCm71lRzqg5IiQXkMgbgZDC2miMrZtD+aGrcWendQTMmQBGRTKQ6wkcDV5YD2h9OdlM0daZ8RjB97ci2zaUZnH04cdnGO8+bbQ44LcDXmTlDFBJGQe3TaJYxPDi8dZOrzV/vAUVRiE4nMPVp8W73sWBrCXDm3sRawpKow+XlBacVa12MMUgR3+bTpQu16wXwDh5JSUdGzGNmJpbT9MLWnuMNs2WRTt1/v48zW17abDw8lXZUVbAQeAbE8CgpaviRVBFfkpCTJMOFfFPPie3PV5hY3LT2ju9mQT024tQ5eBt43lyKnJFkiGLWkCs9awhy5PbJnWUXyh0MDQ0FGIYJFAjdEFkU9iZldkta5qxFgGAuoMouo65C+miitbdGrFFN04YbGxuHfwJN4z4gjV4RIAAAAABJRU5ErkJggg=="); }' +

      // Notifications Reloaded.
      '#new_notifications_popup .notifications, #new_notifications_popup .messages, #new_notifications_popup .friend_requests { max-height: 160px; width: 100%; overflow: auto; }' +
      '#new_notifications_popup .notifications .notifications_item, #new_notifications_popup .messages .notifications_item { width: 100%; }' +

      // Posts Archive.
      '#th-pa-wrapper * { box-sizing: border-box; }' +
      '#th-pa-wrapper { float: left; border: 0px solid rgba(0,0,0,0.1); border-collapse: collapse; width: 100%; background: white; margin: 10px 0; background-color: #f6f7f8; }' +
      '#th-pa-wrapper ul { margin: 0; }' +

      '#th-pa-filter-empty { padding: 10px; font-weight: bold; background-color: #eee; }' +
      '#th-pa-filter { padding: 4px 10px; font-weight: bold; }' +
      '#th-pa-filter ul { display: inline-block; }' +
      '#th-pa-filter label { cursor: pointer; font-weight: normal; }' +

      '#th-pa-table { border: 0; border-collapse: collapse; width: 100%; line-height: 20px; }' +
      '#th-pa-table thead { font-size: 1.5em; background-color: #fff; text-align: left; border-bottom: 1px solid rgba(0,0,0,0.05); }' +
      '#th-pa-table th { padding: 10px; }' +
      '#th-pa-table th:not(:first-child), #th-pa-table td:not(:first-child) { width: 40px; text-align: center; border-left: 1px solid rgba(0,0,0,0.05); }' +

      '.th-pa-row-even { background-color: #eee; }' +
      '.th-pa-row-odd  { background-color: #fff; }' +

      '#th-pa-loader-div { padding: 15px; font-weight: bold; }' +
      '#th-pa-loader-div img { vertical-align: middle; margin-right: 5px; }' +

      '#th-pa-table thead .icon   { margin: 0; width: 24px; height: 24px; }' +
      '#th-pa-table .view_icon    { background-position: -202px -7px; }' +
      '#th-pa-table .share_icon   { background-position: -232px -7px; }' +
      '#th-pa-table .like_icon    { background-position: -263px -7px; }' +
      '#th-pa-table .comment_icon { background-position: -292px -7px; }' +

      '.th-pa-privacy .privacy_icon_private { background: url("/assets/friends_icon.png") no-repeat !important; background-size: 15px !important; }' +
      '.th-pa-privacy #privacy_icon span { width: 15px; height: 15px; }' +
      '.th-pa-privacy #privacy_icon span img { width: 15px; height: 15px; }' +

      '.th-pa-pt-post     .th-pa-pt { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACkUlEQVQ4T3XSW0hTcRwH8O9/N5vb2XTeNufCG0q2lKWYCoGQURQiPSzpISiIQnzoQkbgS9FDINGjUb30kqQUBEIaURR7qNzyNmuoZdPMXd3cObtfPJ0lyg7T39Phd37/D1/4/Qgy6r0t8BBgL2X2tr9ZliUikXCIoYP9nU2l3u0+yRx+Z/PT+v1KapNls4yZX15o1Xms2xd8mgig71RLAZ0e4gHj3310lVZBJVLZwI+fLhxrKMXKRpx1ekJPAkz4+tk2XYQHvLGu0+WlCiqa3MxKsGT34HSjFtEkCzuHOFzM42AoeoMHjE576coyJWUZG+YB+uNG/F7eAtIV4ZBlf5xd5RAe8NrioavL83ZNYOeAzJJLJYjGCcMDXn110zWV+dTkOD9BXYcR6z5m5336kVBAsEHHozvAA5PysoTUPkqyCwJZjh4a5QWoFed32+j/nlQkgG3Rt5Vg4EPBoEZV21OuboZMqkIo4oPdOYHAnyJQG8ZdEcPJbszNrzPk/lt1n66kbqBS24hQzI1EKgyxMBdCNg8rnknIREboCq9kIVKxADM2D0PujemW2vSdFeGEgwPWkGRj2IyLYTGF0XWmHeZpEzT+W1lAA5dgas7NkLujVWxrfTu+mCcgz49DqQIsnxIoLMnF0bYj+Dz7ER0GKwgR8pBcLoFl1sWQ/pc1S02HDlesOuZhNvmxT0ogVxIYWoogl1RhxrqIisjV7AQnumGecjLk9osDfZpizYC6RAKHy4EFaxCGViUUsmL8dURQQJ1DddnFLCCdYOLb2tYWbj6vHyxS5fdoiimIc1JIxIRwuhnEgs2JQllvdK9dBvzekZ07uPassYcb7OXu42Ayllr0rsWGh+7YRvY8BCDE/XP9AzObEEcy9WOXAAAAAElFTkSuQmCC"); }' +
      '.th-pa-pt-share    .th-pa-pt { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACaElEQVQ4T4XSX0hTURwH8O+5u9e8btetP1JuLUrBSIuRFUQvlpWQFiihyIJ6sYcei3qqhx6lB4nqxVw9BEX0EPQHKyKE/hCUIpZuRKl3Nsttbrpzt3nd3D3daZPdZnXgci+/c36f++P8fgR565Uv1gWwjvxY7psxRnjedE+h8YvHdtunc3GSf/ilb4Zu32SVNMYKjKHRaTg22FgoGu9Jx3Chce9amj1kAJ6PRGmlo1RKZwoB77cgDrrsmJhNsalw4mZMSZ5t2+ecMwC9nyN0c7lZUlMLgEkwVDEmh9G0ywF1gUHWkZ9BpTueUM8ZgCeDYVpxq1YihMN8ew+0yrplZNy/BGTXnI74Z1IsoCMG4PG7MWXL/QaLvc6Nyb47WDh+FVr1kcUkWQfyl0UsgpoiChlwl3YxQhZvnvGihRfNpKrhBJLxOAJvHyLT3Am2swWRqLKcn/2riSOYpSmVvG+1UteZKxJLJXVBQybyHZlJL0iJDWmrE/LrR9DcN0C2HTBUIPIcfF+jCnnTbKM1hxulTFjO1gCmJqAloiCCCMG5A3PmcshBCr7DUwAMf4kopK/JRqtrt0rpwIix9xwPZnVgIpQGTl2DqeaPCgQOQ76wQl4cKu0Cxy1Nn6DfgcCTqrJ5zDMTArOrQI6eR1HD6YLBEnVgcDikGLpw+6lX2djTaKmoWIPx0QiE1ksoqj+50mSjRAf6PwWNgKd3lJZfr5cIx6HYfRnF+9tXTM4Gs8DHwSkj0P3MT/e4yqRkIql3Qfprcg74MPDDCHTe7fdYV69r+2dm3mZsZvqB4Q70PbP+rP/9/p+T0A8EfwHTFvU4JDA5ZgAAAABJRU5ErkJggg=="); }' +
      '.th-pa-pt-wallpost .th-pa-pt { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACeElEQVQ4T4WTXUiTURzGn/djc7pPW2vTqZgf3Wh+ZMgM0sI7LaSLjEC60ouii5K8KC9UiOimCLqIMoJIo7S7QIIQEqILNcXUVqx01nTfe91edWvOnd7X5dt0ix44nMOf5/z4f5xDIUmj1uBdgLQnx3bOhBCKZZnnfGit+/TRXN9OnEo2v7VyofICrTpOSApj5rsPZpOOeAJr/ZtBdDVZ9CHRtAvwZj4QKjJlqWNgUgCfv7nRWJmLH6tR4vKuPwryG1dbj+WHJcDQ2TK536AN/+KWaVaTj9quF2DVBgm0YPeiucaMSIzALkCcbv7h2nqkUwI86ai+RRj5daMpB06HC0ZLC8ynOiXA4lICICosQJa4KHEIEAkwcLl2odpy8qB2nxYrPx2wr3hRdHFAAtgFQLJUmXJEohQvAV52WnoLD1X06A1GLC8tIlrYAF3dBemOP8BLZ/ESQ1NYDUUjEuDZpSMlSo3GptLowK9yMLQ9gMJQnG6i27FMlobVFkhkcHNUb1Zw7J268ivn3K/GUHC+BbP2Dygu7UOGLFH3XomAua9+nup9ndeuyJD3l+bX4HBeC/iwE5lyDb44R2FzTKHE1AOzvjUVIKMxY/XyVPdw6VSjpbmaYePYiPqxFd8U6pMJED3iMRoTswuorxpOC5ie8/DUtcHKWFNDPRPc8Ahd5UDIFiiKgUKeDZ3SiJF3YzhzYjwFkCVkMPnJzVMd96qeKtVsG2iKTldrjq4Rx2v70gImpl3bTVQJyygs9Y37I+8ryg4oI7F4otNKFcSxivteiRmMf1z5+w5Ew+3Bycfa7P2pHfvHMIOcb2jXZxJ8yj/ZiPv/tC4Y3L8B/vXnOFesbhoAAAAASUVORK5CYII="); }' +
      '.th-pa-picture { float: right; width: 70px; height: 70px; padding: 5px; text-align: center; }' +
      '.th-pa-picture img { max-width: 60px; max-height: 60px; }' +
      '.th-pa-post { float: left; width: 480px; padding: 5px; min-height: 70px; }' +
      '.th-pa-title { font-weight: bold; }' +
      '.th-pa-title, .th-pa-content { white-space: pre-line; }' +
      '.th-pa-ellipsis { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }' +
      '.th-pa-meta { opacity: 0.6; }' +
      '#th-pa-table tr:hover .th-pa-meta { opacity: 1; }' +
      '.th-pa-meta li, #th-pa-filter li { display: inline-block; margin-right: 10px; }' +
      '.th-pa-expand { float: none; padding: 0px; width: 13px; height: 13px; vertical-align: text-top; }' +
      '.th-pa-post-link, .th-pa-original-link { float: right; }' +
      '.th-pa-stealth { display: none !important; }' +
      '#th-pa-table tr:hover .th-pa-stealth { display: inline-block !important; }'
    ).appendTo( 'head' );
  }

  /**
   * Add the about window which shows the version and changelog.
   * It also displays donate buttons and an update button if a newer version is available.
   */
  function addAboutWindow() {
    doLog( 'Added about window.', 'i' );

    // About window.
    var $aboutWindow = $( '<div/>', {
      'id' : 'th-aw',
      html :
        '<h1>About Tsu Helper</h1>' +
        '<div class="th-about-love"><i class="th-icon th-icon-heart"></i>Made with love and care.</div>' +
        '<div><ul class="th-aw-info">' +
          '<li>Version <strong>' + Updater.localVersion + '</strong> (<a href="https://j.mp/tsu-helper-changelog" target="_blank">changelog</a>)<br />' +
          '<li>&copy;2014-2015 Armando L&uuml;scher (<a href="/noplanman">@noplanman</a>)<br />' +
          '<li><em>Disclaimer</em>: Tsu Helper is in no way affiliated with Tsu LLC.' +
          '<li>Use it at your own risk.' +
        '</ul></div>' +
        '<div><i class="th-icon-manual"></i>For more details about this script, to see how it works,<br />and an overview of all the features and how to use them,<br /> take a look at the extensive manual <a href="https://j.mp/tsu-helper-readme" target="_blank">here</a>.</div>' +
        '<div><ul class="th-aw-get-in-touch">' +
          '<li>Found a <i class="th-icon th-icon-bug" title="Bug"></i>' +
          '<li>Have a great <i class="th-icon th-icon-idea" title="Idea"></i>' +
          '<li>Just want to say hi?' +
          '<li><a class="message_pop_up fancybox.ajax" href="/messages/new/noplanman">Let me know!</a>' +
        '</ul></div>' +
        '<div>If you like this script and would like to support my work, please consider a small donation. It is very much appreciated <i class="th-icon th-icon-heartp"></i>' +
          '<div class="th-buttons-div th-aw-donate-buttons">' +
            '<a class="th-aw-donate-paypal" href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=CRQ829DME6CNW" target="_blank"><img alt="Donate via PayPal" title="Donate via PayPal" src="https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif" /></a>' +
            '<span>&laquo; PayPal <i> - or - </i> Tsu &raquo;</span>' +
            '<a class="th-aw-donate-tsu button message_pop_up fancybox.ajax donation" href="/users/profiles/donation/104738" title="Donate via Tsu">Donate</a>' +
          '</div>' +
        '</div>' +
        '<div id="th-about-followme">Follow me and stay up to date!</div>' +
        '<div>Iconset <a href="https://www.iconfinder.com/iconsets/essen" target="_blank">Essen</a> by <a href="http://pc.de" target="_blank">PC.de</a></div>'
    });

    // Get my card and add it to the about window.
    $.get( '/users/profile_summary/104738', function( card ) {
      $aboutWindow.find( '#th-about-followme' ).after( card );
    });

    // Settings window.
    var $settingsWindow = $( '<div/>', {
      'id' : 'th-sw',
      html : '<h1>Tsu Helper Settings</h1>'
    });

    // Settings which are only a checkbox.
    var checkboxSettings = '';
    [
      { name : 'hideAds',      txt : 'Hide Ads',                       help : 'Show or Hide all the Ads.' },
      { name : 'quickMention', txt : 'Enable Quick Mentions',          help : 'Add Quick Mention links to comments and replies.' },
      { name : 'emphasizeNRP', txt : 'Emphasize Nested Replies',       help : 'Emphasize the parent of nested comment replies, to make them more visible.' },
      { name : 'checkSocial',  txt : 'Check Social Networks',          help : 'Check if your new post is being shared to your connected Social Network accounts.' },
      { name : 'checkMaxHM',   txt : 'Check Max. Hashtags & Mentions', help : 'Check if the maximum number of Hashtags & Mentions has been reached before posting.' }
    ].forEach(function( item ) {
      checkboxSettings += '<div><label><input type="checkbox" name="' + item.name + '"' + checked( settings[ item.name ] ) + ' />' + item.txt + '</label><i class="th-icon th-icon-help th-sw-help" title="' + item.help + '"></i></div>';
    });

    // The debug level dropdown.
    var debugLevelSettings = ( publicDebug || 104738 === window.current_user.id ) ?
      '<div><label>Debug level: ' +
        '<select name="debugLevel">' +
          '<option value="disabled"' + selected( 'disabled', settings.debugLevel ) + '>Disabled</option>' +
          '<option value="l"' + selected( 'l', settings.debugLevel ) + '>Log</option>' +
          '<option value="i"' + selected( 'i', settings.debugLevel ) + '>Info</option>' +
          '<option value="w"' + selected( 'w', settings.debugLevel ) + '>Warn</option>' +
          '<option value="e"' + selected( 'e', settings.debugLevel ) + '>Error</option>' +
        '</select>' +
      '</label></div>' : '';

    // List the available count options.
    var selectNotifReloaded = '<select name="notifReloaded"><option value="0">Disabled</option>';
    [ 5, 10, 15, 20, 25, 30 ].forEach(function( val ) {
      selectNotifReloaded += '<option value="' + val + '">' + val + '</option>';
    });
    selectNotifReloaded += '</select>';

    var $settingsForm = $( '<form/>', {
      'id' : 'th-settings-form',
      html :
        checkboxSettings +=

        // FFC.
        '<div><label>Show Friends and Followers on ' +
          '<select name="showFFC">' +
            '<option value="disabled"' + selected( 'disabled', settings.showFFC ) + '>No Links (disabled)</option>' +
            '<option value="all"' + selected( 'all', settings.showFFC ) + '>All Links</option>' +
            '<option value="hovercard"' + selected( 'hovercard', settings.showFFC ) + '>Hover Cards Only</option>' +
          '</select>' +
        '</label><i class="th-icon th-icon-help th-sw-help" title="Where to display Friends and Followers counts."></i></div>' +

        // Notifications Reloaded
        '<div><label>Notifications Reloaded count: ' +
          selectNotifReloaded +
        '</label><i class="th-icon th-icon-help th-sw-help" title="How many notifications to show in the notification popup."></i></div>'

        + debugLevelSettings
    })
    .appendTo( $settingsWindow );

    // Defaults button on Settings window.
    var $defaultsButton = $( '<a/>', {
      'id'  : 'th-sw-defaults-button',
      class : 'button red',
      title : 'Reset to default values',
      html  : 'Defaults',
      click : function() {
        Settings.setDefaults( $settingsForm );
      }
    })
    .appendTo( $settingsWindow.find( 'h1' ) );

    // The state in which the Settings are closed (back or save).
    var settingsCloseState = null;

    // Save button on Settings window.
    var $saveButton = $( '<a/>', {
      'id'  : 'th-sw-save-button',
      class : 'button',
      title : 'Save Settings',
      html  : 'Save',
      click : function() {
        if ( confirm( 'Refresh page now for changes to take effect?' ) ) {
          Settings.save( $settingsForm );
          settingsCloseState = 'save';
          $.fancybox.close();
        }
      }
    });

    // Back button on Settings window.
    var $backButton = $( '<a/>', {
      'id'  : 'th-sw-back-button',
      class : 'button grey',
      title : 'Go Back without saving',
      html  : '&laquo; Back',
      click : function() {
        // Close this window.
        settingsCloseState = 'back';
        $.fancybox.close();
      }
    });

    // Buttons on Settings window.
    $( '<div/>', {
      class : 'th-buttons-div',
      html  : '<span><a href="https://j.mp/tsu-helper-settings" target="_blank">Detailed Help</a></span>'
    })
    .prepend(  $backButton )
    .append(   $saveButton )
    .appendTo( $settingsWindow );


    // Settings button on About window.
    $( '<a/>', {
      'id'  : 'th-aw-settings-button',
      title : 'Change Settings',
      html  : '',
      click : function() {
        // Open settings window in a fancybox.
        Settings.populateForm( $settingsForm );
        $.fancybox( $settingsWindow, {
          closeBtn : false,
          modal : true,
          beforeClose : function() {
            // If the Back button was pressed, reopen the About window.
            if ( 'back' === settingsCloseState ) {
              setTimeout(function() {
                $.fancybox( $aboutWindow );
              }, 10);
              return false;
            }
          },
          afterClose : function() {
            // If the Save button was pressed, reload the page.
            if ( 'save' === settingsCloseState ) {
              location.reload();
              return;
            }
          }
        });
      }
    })
    .appendTo( $aboutWindow.find( 'h1' ) );


    // Check if there is a newer version available.
    if ( Updater.hasUpdate() ) {
      $( '<a/>', {
        'id'  : 'th-aw-update-button',
        class : 'button th-update',
        title : 'Update Tsu Helper to the newest version (' + Updater.remoteVersion + ')',
        href  : Updater.scriptURL,
        html  : 'New Version!',
        click : function() {
          if ( ! confirm( 'Upgrade to the newest version (' + Updater.remoteVersion + ')?\n\n(refresh this page after the script has been updated)' ) ) {
            return false;
          }
        }
      })
      .attr( 'target', '_blank' ) // Open in new window / tab.
      .appendTo( $aboutWindow.find( 'h1' ) );
    }

    // Link in the menu that opens the about window.
    var $aboutWindowLink = $( '<a/>', {
      title : 'About noplanman\'s Tsu Helper',
      html  : 'About Tsu Helper',
      click : function() {
        // Close the menu.
        $( '#navBarHead .sub_nav' ).hide();

        // Open about window in a fancybox.
        $.fancybox( $aboutWindow );
      }
    });

    // Check if there is a newer version available.
    if ( Updater.hasUpdate() ) {
      // Change the background color of the name tab on the top right.
      $( '#navBarHead .tab.name' ).addClass( 'th-update' );
      $aboutWindowLink.addClass( 'th-update' );
    }

    // Add "About" menu item.
    $( '<li/>', { 'id' : 'th-menuitem-about', html : $aboutWindowLink } )
    .appendTo( '#navBarHead .sub_nav' );
  }

});