SB100 / PTP Infinite Scroll

// ==UserScript==
// @namespace    https://openuserjs.org/users/SB100
// @name         PTP Infinite Scroll
// @description  Infinitely scroll on paginated PTP pages
// @updateURL    https://openuserjs.org/meta/SB100/PTP_Infinite_Scroll.meta.js
// @version      1.2.2
// @author       SB100
// @copyright    2021, SB100 (https://openuserjs.org/users/SB100)
// @license      MIT
// @match        https://passthepopcorn.me/torrents.php*id=*
// @match        https://passthepopcorn.me/forums.php?*action=view*
// @match        https://passthepopcorn.me/collages.php*
// @exclude      https://passthepopcorn.me/collages.php*id=*
// @match        https://passthepopcorn.me/requests.php*
// @exclude      https://passthepopcorn.me/requests.php?*action=new*
// @grant        GM_xmlhttpRequest
// ==/UserScript==

// ==OpenUserJS==
// @author SB100
// ==/OpenUserJS==

/* jshint esversion: 6 */

const SETTING_SHOW_REPLY_WHEN_AT_BOTTOM_OF_PAGE = true;

/**
 * Different pages have different table ids/classnames - lets set them up here
 */
let tableSelector;
let rowSelector;
let floatingElement;

function setTableSelector() {
  const href = window.location.href;

  // individual movie page (torrent comments)
  if (href.includes('torrents.php')) {
    tableSelector = '#comments-container';
    rowSelector = `${tableSelector} .forum-post`;
    floatingElement = '#reply_box';
    return true;
  }

  // forum viewthread page
  if (href.includes('forums.php') && href.includes('threadid=')) {
    tableSelector = '.thin';
    rowSelector = `${tableSelector} .forum_post`;
    floatingElement = '#reply_box';
    return true;
  }

  // forum viewforum page
  if (href.includes('forums.php')) {
    tableSelector = '.forum_list';
    rowSelector = `${tableSelector} tbody tr`;
    return true;
  }

  // main collections page
  if (href.includes('collages.php')) {
    tableSelector = '.table';
    rowSelector = `${tableSelector} tbody tr`;
    return true;
  }

  // individual request page
  if (href.includes('requests.php') && href.includes('id=')) {
    tableSelector = '.main-column';
    rowSelector = `${tableSelector} .forum-post`;
    floatingElement = '#reply_box';
    return true;
  }

  // main requests page
  if (href.includes('requests.php')) {
    tableSelector = '#request_table';
    rowSelector = `${tableSelector} tbody tr`;
    return true;
  }

  return false;
}

/**
 * Simple throttle function
 */
const throttle = (func, limit) => {
  let lastFunc
  let lastRan
  return function () {
    const context = this
    const args = arguments
    if (!lastRan) {
      func.apply(context, args)
      lastRan = Date.now()
    }
    else {
      clearTimeout(lastFunc)
      lastFunc = setTimeout(function () {
        if ((Date.now() - lastRan) >= limit) {
          func.apply(context, args)
          lastRan = Date.now()
        }
      }, limit - (Date.now() - lastRan))
    }
  }
}

/**
 * Create a loading indicator to show the user we're doing something
 */
let loadingElementReference;

function getLoadingElementReference() {
  if (!loadingElementReference) {
    loadingElementReference = document.createElement('div');
    loadingElementReference.innerHTML = 'Loading ...';
    loadingElementReference.style.color = '#fff';
    loadingElementReference.style.lineHeight = '30px';
  }

  return loadingElementReference;
}

/**
 * Checks if we're at the bottom of the page. If so, let's auto show the reply box
 */
function handleScroll(elem) {
  const linkBox = document.querySelector(`.pagination--bottom`);
  const nextAnchorElem = findNextLinkFromPagination(linkBox);

  // if there is no next page, no point in checking the scroll position
  if (!nextAnchorElem || nextAnchorElem.href.endsWith('null')) {
    if ((window.innerHeight + window.pageYOffset) >= document.body.offsetHeight) {
      elem.classList.add('floating--slide');
      document.querySelector('.footer').classList.toggle('footer--extra-margin');
      window.onscroll = null;
    }
  }
}

/**
 * Makes the reply box a floating bar at the bottom of the page
 */
function setupFloatingElement() {
  if (floatingElement) {
    const transformHeight = window.getComputedStyle(document.querySelector(`${floatingElement} .forum-post__avatar-and-body`));

    const style = document.createElement('style');
    style.type = 'text/css';
    style.innerHTML = `.floating--slide { transform: translateY(0)!important; } .footer--extra-margin { margin-bottom: calc(${transformHeight.height} + 35px)}`;
    document.getElementsByTagName('head')[0].appendChild(style);

    const elem = document.querySelector(floatingElement);
    elem.style.position = 'fixed';
    elem.style.bottom = 0;
    elem.style.transform = `translateY(${transformHeight.height})`;
    elem.style.maxWidth = '975px';
    elem.style.margin = 0;
    elem.style.zIndex = 2;
    // set this a tick later so that we don't get the initial transition
    setTimeout(() => {
      elem.style.transition = 'transform 0.25s ease-out';
    }, 1);

    const elemHeader = document.querySelector(`${floatingElement} .forum-post__heading`)
    elemHeader.style.cursor = 'pointer';
    elemHeader.onclick = () => {
      elem.classList.toggle('floating--slide');
      document.querySelector('.footer').classList.toggle('footer--extra-margin');
    }

    if (SETTING_SHOW_REPLY_WHEN_AT_BOTTOM_OF_PAGE) {
      window.onscroll = throttle(handleScroll.bind(null, elem), 500);
    }
  }
}

/**
 * Turn a HTML string into a HTML element so that we can run querySelector calls against it
 */
function htmlToElement(html) {
  var template = document.createElement('template');
  html = html.trim();
  template.innerHTML = html;
  return template.content;
}

/**
 * Append new rows to the current rows
 */
function appendRows(lastRowSelector, newRows) {
  newRows.forEach(row => {
    const currRows = document.querySelectorAll(lastRowSelector);
    currRows[currRows.length - 1].insertAdjacentElement('afterEnd', row);
  });
}

/**
 * Find the "next" anchor element from the pagination element.
 */
function findNextLinkFromPagination(paginationElement) {
  if (!paginationElement) return null;
  const nextAnchorArray = Array.from(paginationElement.querySelectorAll('a')).filter(a => a.innerText.startsWith('Next'));
  return nextAnchorArray.length > 0 ? nextAnchorArray[0] : null;
}

/**
 * On successful xmlRequest, process the results
 */
function xmlOnLoad(linkBox, successCallback, result) {
  // must be a successful page load
  if (result.status !== 200) {
    return;
  }

  // remove the loading indicator
  linkBox.removeChild(getLoadingElementReference());

  // turn into html
  const html = htmlToElement(result.response);
  // get all the trs in the table body
  const trs = Array.from(html.querySelectorAll(rowSelector)).filter(tr => tr.classList.contains('forum-post--unread') === false);
  // get the next href link
  const nextHref = findNextLinkFromPagination(html.querySelector(`.pagination--bottom`));

  // append to the current table
  appendRows(rowSelector, trs);

  // update old next href with new next href
  successCallback(nextHref);
}

/**
 * On a failed xmlRequest, show an error message and cancel the observer
 */
function xmlOnFailure(linkBox, failureCallback) {
  linkBox.removeChild(getLoadingElementReference());

  const errorDiv = document.createElement('div');
  errorDiv.innerHTML = 'There was an error loading the next page. Aborting infinite scroll';
  errorDiv.style.color = '#ff3232';
  errorDiv.style.lineHeight = '30px';

  linkBox.insertBefore(errorDiv, linkBox.firstChild);

  failureCallback();
}

/**
 * Loads the next page by:
 * - Loading the URL
 * - Turning the result into a HTML element
 * - Finding the results table and appending its rows to the current table
 * - Running a callback to update the "next" link to the "next" link thst was loaded from this call
 */
function loadNextPage(url, linkBox, successCallback, failureCallback) {
  GM_xmlhttpRequest({
    method: 'get',
    url: url,
    timeout: 5000,
    onloadstart: function () {
      linkBox.insertBefore(getLoadingElementReference(), linkBox.firstChild);
    },
    onload: xmlOnLoad.bind(null, linkBox, successCallback),
    onerror: xmlOnFailure.bind(null, linkBox, failureCallback),
    ontimeout: xmlOnFailure.bind(null, linkBox, failureCallback)
  });
}

/**
 * Setup the intersection observer, and load the next page when we get to the bottom of the page
 */
(function () {
  'use strict';

  // check we have observers available to us
  if (!IntersectionObserver) return;

  // check we're on a compatible page
  if (!setTableSelector()) {
    console.log('[PTP Infinite Scroll] Not on a compatible page');
    return;
  }

  setupFloatingElement();

  // find the pagination element
  const linkBox = document.querySelector(`.pagination--bottom`);

  // what to do when we find the element in the root element
  const callback = function (entries, observer) {
    entries.forEach(entry => {
      // we're intersecting the pagination - find the next page and load it
      if (entry.isIntersecting !== true) {
        return
      }

      // find the "next" page link from the results
      const nextAnchorElem = findNextLinkFromPagination(entry.target);

      // if there is no next page, there is nothing more to do!
      if (!nextAnchorElem || nextAnchorElem.href.endsWith('null')) {
        // stop observing
        observer.unobserve(linkBox);

        // return early
        return;
      }

      // otherwise:
      // success: append the results of the next page to the current table and update "next" link
      // failure: stop observing the page - there are no more pages to load
      loadNextPage(nextAnchorElem.href, linkBox, (nextHref) => {
        // update the "next" link with the next "next" link!
        nextAnchorElem.href = nextHref;
      }, () => {
        // stop observing
        observer.unobserve(linkBox);
      });
    });
  };

  const options = {
    root: document,
    rootMargin: '0px',
    threshold: 1.0
  };

  // create the observer
  const observer = new IntersectionObserver(callback, options);

  // Start observing the target node for mutations
  observer.observe(linkBox, options);
})();