SB100 / BTN Infinite Scroll

// ==UserScript==
// @name         BTN Infinite Scroll
// @namespace    https://openuserjs.org/users/SB100
// @description  Infinitely scroll on paginated BTN pages
// @version      1.0.0
// @author       SB100
// @copyright    2023, SB100 (https://openuserjs.org/users/SB100)
// @license      MIT
// @updateURL    https://openuserjs.org/meta/SB100/BTN_Infinite_Scroll.meta.js
// @match        https://broadcasthe.net/torrents.php*
// @match        https://broadcasthe.net/tvnews.php*
// @match        https://broadcasthe.net/reviews.php*
// @match        https://broadcasthe.net/collages.php*
// @match        https://broadcasthe.net/requests.php*
// @match        https://broadcasthe.net/actorshowcase.php*
// @match        https://broadcasthe.net/recommend.php*
// @match        https://broadcasthe.net/forums.php*
// @grant        GM.xmlHttpRequest
// ==/UserScript==

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

/**
 * =============================
 * ADVANCED OPTIONS
 * =============================
 */

// will automatically open the reply box when you get to the bottom of the
// infinite scroll list, if a reply box is available
const SETTING_SHOW_REPLY_WHEN_AT_BOTTOM_OF_PAGE = false;

/**
 * =============================
 * END ADVANCED OPTIONS
 * DO NOT MODIFY BELOW THIS LINE
 * =============================
 */

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

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

  // torrent browse page
  if (href.includes('torrents.php')) {
    tableSelector = '#torrent_table';
    rowSelector = `${tableSelector} .torrent`;
    return true;
  }

  // tv news
  if (href.includes('tvnews.php')) {
    tableSelector = '.main_column';
    rowSelector = `${tableSelector} .box`;
    return true;
  }

  // reviews
  if (href.includes('reviews.php')) {
    tableSelector = '.thin2';
    rowSelector = `${tableSelector} .box`;
    return true;
  }

  // reviews
  if (href.includes('collages.php')) {
    tableSelector = '#torrent_table';
    rowSelector = `${tableSelector} tr:not(.colhead)`;
    return true;
  }

  // requests
  if (href.includes('requests.php')) {
    tableSelector = '.thin > table.border';
    rowSelector = `${tableSelector} .rowa, .rowb`;
    return true;
  }

  // actorshowcase
  if (href.includes('actorshowcase.php')) {
    tableSelector = '.thin > table';
    rowSelector = `${tableSelector} > tbody > tr`;
    return true;
  }

  // recommend
  if (href.includes('recommend.php')) {
    tableSelector = '.thin .box > table';
    rowSelector = `${tableSelector} tr:not(.colhead)`;
    return true;
  }

  // forums view forum
  if (href.includes('forums.php') && href.includes('forumid')) {
    tableSelector = '.thin > table';
    rowSelector = `${tableSelector} .rowa, .rowb`;
    return true;
  }

  // forums view thread
  if (href.includes('forums.php') && href.includes('threadid')) {
    tableSelector = '.thin';
    rowSelector = `${tableSelector} > table`;
    hasFloatingElement = true;
    return true;
  }

  return false;
}

/**
 * Simple throttle function
 */
const throttle = (func, limit) => {
  let lastFunc;
  let lastRan;
  return function inner() {
    const context = this;
    // eslint-disable-next-line prefer-rest-params
    const args = arguments;
    if (!lastRan) {
      func.apply(context, args);
      lastRan = Date.now();
    }
    else {
      clearTimeout(lastFunc);
      lastFunc = setTimeout(() => {
        if (Date.now() - lastRan >= limit) {
          func.apply(context, args);
          lastRan = Date.now();
        }
      }, limit - (Date.now() - lastRan));
    }
  };
};

/**
 * Turn a HTML string into a HTML element so that we can run querySelector calls against it
 */
function htmlToElement(html) {
  const template = document.createElement('template');
  template.innerHTML = html.trim();
  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;
}

/**
 * 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 = Array.from(
    // misspelled on /recommend.php
    document.querySelectorAll('.linkbox, .linxbox')
  ).slice(-1)[0];
  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 - 10
    ) {
      elem.classList.add('floating--slide');
      window.onscroll = null;
    }
  }
}

/**
 * Makes the reply box a floating bar at the bottom of the page
 */
function setupFloatingElement() {
  if (hasFloatingElement) {
    const postReply = document.querySelector('h3');
    const replyBox = document.querySelector('h3 + .box.pad');

    const elem = document.createElement('div');
    elem.appendChild(postReply);
    elem.appendChild(replyBox);
    document.querySelector('#content').appendChild(elem);

    const transformHeight = window.getComputedStyle(elem).height;

    elem.style.position = 'fixed';
    elem.style.bottom = '0';
    elem.style.left = '50%';
    elem.style.transform = `translateY(${transformHeight}) translateY(-23px) translateX(-50%)`;
    elem.style.maxWidth = '960px';
    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 = elem.querySelector(`h3`);
    elemHeader.style.background = 'rgba(0, 0, 0, 0.7)';
    elemHeader.style.padding = '10px';
    elemHeader.style.margin = '0';
    elemHeader.style.cursor = 'pointer';
    elemHeader.onclick = () => {
      elem.classList.toggle('floating--slide');
    };

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

    const style = document.createElement('style');
    style.innerHTML = `.floating--slide { transform: translateX(-50%) translateY(0)!important;}`;
    document.getElementsByTagName('head')[0].appendChild(style);
  }
}

/**
 * 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 newLinkBox = Array.from(
    // misspelled on /recommend.php
    html.querySelectorAll('.linkbox, .linxbox')
  ).slice(-1)[0];
  const nextHref = findNextLinkFromPagination(newLinkBox);

  // 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,
    timeout: 5000,
    onloadstart() {
      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 main() {
  // check we have observers available to us
  if (!IntersectionObserver) return;

  // check we're on a compatible page
  if (!setTableSelector()) {
    // eslint-disable-next-line no-console
    console.log('[BTN Infinite Scroll] Not on a compatible page');
    return;
  }

  setupFloatingElement();

  // find the last pagination element
  const linkBox = Array.from(
    // misspelled on /recommend.php
    document.querySelectorAll('.linkbox, .linxbox')
  ).slice(-1)[0];

  // what to do when we find the element in the root element
  const callback = function callback(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);
})();