Planetsuzy Threads - Auto fold posts

This script will automatically fold some of the forum posts based on their content

// ==UserScript==
// @namespace Planetsuzy
// @name Planetsuzy Threads - Auto fold posts
// @description This script will automatically fold some of the forum posts based on their content
// @version 1.3.1
// @license MIT
// @icon http://ps.fscache.com/styles/style1/images/statusicon/forum_old.gif
// @include /^https?://(www\.)?planetsuzy\.org/showthread\.php/
// @include /^https?:\/\/(www\.)?planetsuzy\.org\/t\d+[\w-]+\.html$/
// @require https://cdnjs.cloudflare.com/ajax/libs/autolinker/1.8.3/Autolinker.min.js
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// ==/UserScript==

const decoratePosts = function() {

  /**
   * Posts containing only links from these domains will be less visible (values stored in settings).
   */
  const blacklistedDomains = GM_getValue('blacklistedDomains', []);

  /**
   * Returns two top levels of the URL's hostname.
   */
  const parseDomain = function (href) {
    return new URL(href, document.location).hostname.split('.').slice(-2).join('.');
  };

  /**
   * Returns 'inner' URL if it wrapped by a link anonymizer.
   */
  const unwrapUrl = function (href) {
    const baseUrl = new URL(href, document.location);
    if (baseUrl.pathname === '/' && baseUrl.search.length > 1) {
      const innerParam = baseUrl.search.slice(1);
      try {
        new URL(innerParam);
        return innerParam;
      } catch (e) {}
    }
    return href;
  };

  /**
   * Store domain of current document for later.
   */
  const documentDomain = parseDomain(document.location.href);

  /**
   * Class representing list of links in each post message sorted by link domains.
   */
  class LinksList {
    constructor() {
      this.links = {};
    }
    add(href) {
      let domain;
      try {
        domain = parseDomain(unwrapUrl(href));
      } catch (e) {
        // Invalid link, ignore.
        return;
      }
      // Local links ignored.
      if (domain !== documentDomain) {
        if (!(domain in this.links)) {
          this.links[domain] = [];
        }
        this.links[domain].push(href);
      }
    }
    get domains() {
      return Object.keys(this.links);
    }
  };

  /**
   * Class representing lists of elements linked with a tag such as a domain name
   * with a function to change multiple elements appearance at once.
   */
  class TaggedElementsList {
    constructor(decoratorFn) {
      this.elements = {};
      this.decoratorFn = decoratorFn;
    }
    addElement(tag, element) {
      if (this.elements[tag] === undefined) {
        this.elements[tag] = [];
      }
      this.elements[tag].push(element);
    }
    filter(tag, fn) {
      if (this.elements[tag] === undefined) {
        return [];
      }
      return this.elements[tag].filter(fn);
    }
    decorateElement(element) {
      const args = Array.from(arguments).slice(1);
      this.decoratorFn.apply(element, args);
    }
    decorateElements(tag) {
      const args = Array.from(arguments).slice(1);
      const that = this;
      this.elements[tag].forEach(function (element) {
        that.decoratorFn.apply(element, args);
      });
    }
  };

  const domainSwitches = new TaggedElementsList(function (isBlacklisted) {
    this.style.fontWeight = isBlacklisted ? 'normal' : 'bold';
    this.style.textDecoration = isBlacklisted ? 'line-through' : 'none';
  });
  const expandToggles = new TaggedElementsList(function (makeVisible) {
    const needTriggerClick = (makeVisible === true && this.getAttribute('data-initial-state') === '0' && this.getAttribute('data-state') === '0')
      || (makeVisible === false && this.getAttribute('data-initial-state') === '0' && this.getAttribute('data-state') === '1');
    if (needTriggerClick) {
      this.click();
    }
  });
  const refreshLabels = new TaggedElementsList(function (makeVisible) {
    this.style.display = makeVisible ? 'inline' : 'none';
  });

  document.querySelectorAll('table[id^=post]')
    .forEach(function (post) {
      // Find messages in thread and examine them for any links.
      const postId = post.getAttribute('id').substring(4);
      const message = post.querySelector('[id^=post_message_]');
      if (message) {
        const links = new LinksList();
        // Find anchors in post message.
        Array.from(message.querySelectorAll('a[href]'))
          .filter(function(link) {
            // Function to determine whether the image element inside anchor can be excluded due to not being a file hoster link.
            const canExcludeImage = function (image) {
              const parseUrlSegments = function (href) {
                // Returns URL pathname except the file name.
                return new URL(href, document.location).pathname.substring(1).split('/').slice(0, -1);
              };
              const isWorthlessLink = function (href) {
                const url = new URL(href, document.location);
                return (url.pathname.length === 1 && url.search.length <= 1)  // href is to website root URL
                  || url.hostname.indexOf('.') === -1;                        // hostname is TLD
              };
              const imageSrc = image.getAttribute('src');
              try {
                const linkHref = unwrapUrl(link.getAttribute('href'));
                const linkDomain = parseDomain(linkHref);
                return isWorthlessLink(linkHref)                                   // exclude if linked to the root of a website ("worthless link")
                  || [linkDomain, documentDomain].includes(parseDomain(imageSrc))  // exclude if same domain or local image
                  || parseUrlSegments(imageSrc).includes(linkDomain);              // exclude if link domain is contained in URL (eg. 3rd part CDN used for previews)
              } catch (e) {
                return false;  // invalid link, exclude for sure
              }
            };
            // Exclude images inside anchors if images hostnames match anchors hostnames (previews) or local images.
            // May cause false positives when download link contains an image (eg icon) of a file hoster directly from its domain name.
            const images = link.querySelectorAll('img');
            if (images.length) {
              const hostnamesExcluded = Array.from(images)
                .reduce(function (acc, cur) { return acc && canExcludeImage(cur) }, true);
              return !hostnamesExcluded;
            }
            return true;
          })
          .forEach(function(link) {
            const deemphasise = function (element) {
              element.style.opacity = '0.2';
            };
            const href = link.getAttribute('href').toString();
            links.add(href);
            try {
              if (blacklistedDomains.includes(parseDomain(unwrapUrl(href)))) {
                deemphasise(link);
              }
            } catch (e) {
              if (link.querySelectorAll('*').length === 0) {
                deemphasise(link);  // invalid link containing no other elements such as image previews, deemphasise for sure
              }
            }
          });
        // Additionally, parse links in 'pre' elements.
        message.querySelectorAll('pre')
          .forEach(function (pre) {
            Autolinker.parse(message.innerHTML, {urls: true})
              .forEach(function (match) {
                links.add(match.getUrl());
              });
          });

        // Set up additional elements.
        const postCountElement = post.querySelector('#postcount' + postId);
        const postAnchorElement = post.querySelector('a[name=' + post.getAttribute('id') + ']');
        const hasBlacklistedLinks = links.domains.length && !links.domains.filter(function (domain) { return !blacklistedDomains.includes(domain) }).length;
        const contentRemoved = message.innerText.replace(/Removed at request of claimed rights holder/, '').length < 10;
        const hiddenPost = hasBlacklistedLinks || contentRemoved;

        // Collapse/reveal post toggle switches.
        postCountElement.parentElement.prepend((function () {
          const labels = ['►', '▼'];
          const initialState = hiddenPost ? 0 : 1;
          const el = document.createElement('a');
          el.innerHTML = labels[1];
          el.setAttribute('data-state', 1);
          el.setAttribute('data-initial-state', initialState);
          el.style.cursor = 'pointer';
          el.addEventListener('click', function (e) {
            const newState = (parseInt(el.getAttribute('data-state')) + 1) % labels.length;
            Array.from(postCountElement.closest('tbody').querySelectorAll(':scope > tr'))
              .filter(function (row) { return row !== postCountElement.closest('tr') })
              .forEach(function (row) { row.style.display = newState ? 'table-row' : 'none' });
            post.parentElement.querySelectorAll('#post_thanks_box_' + postId)
              .forEach(function (row) { row.style.display = newState ? 'block' : 'none' });
            
            el.innerHTML = labels[newState];
            el.setAttribute('data-state', newState);
          });
          if (!initialState) {
            el.click();
          }
          expandToggles.addElement('a', el);
          return el;
        })());

        // Refresh label that shows up to indicate that a refresh is needed to apply changes.
        postCountElement.parentElement.prepend((function () {
          const el = document.createElement('a')
          el.innerHTML = 'F5 ⟳';
          el.classList.add('post-thead-label');
          el.classList.add('button');
          el.style.cursor = 'pointer';
          el.addEventListener('click', function () {
            window.location.reload();
          });
          refreshLabels.addElement('F5', el);
          refreshLabels.decorateElement(el, false);
          return el;
        })());

        // Domain name toggle switches.
        postCountElement.parentElement.prepend((function () {
          const el = document.createElement('span');
          if (hiddenPost) {
            el.appendChild(document.createTextNode('⚠️ '));
          }
          links.domains.forEach(function (domain, i) {
            const ch = document.createElement('span');
            ch.innerHTML = domain;
            ch.style.cursor = 'pointer';
            ch.addEventListener('click', function () {
              // Update domains blacklist property and redecorate elements.
              const oldBlacklist = GM_getValue('blacklistedDomains', []);
              const wasBlacklisted = oldBlacklist.includes(domain);
              const newBlacklist = wasBlacklisted
                ? oldBlacklist.filter(function (value) { return value !== domain})
                : oldBlacklist.concat([domain]);
              domainSwitches.decorateElements(domain, !wasBlacklisted);
              GM_setValue('blacklistedDomains', newBlacklist);
              const needRefresh = blacklistedDomains.length !== newBlacklist.length
                || blacklistedDomains.filter(function(a) { return newBlacklist.indexOf(a) === -1; }).length;
              refreshLabels.decorateElements('F5', needRefresh);
            });
            el.appendChild(ch);
            el.appendChild(document.createTextNode(i < links.domains.length - 1 ? ', ' : ' '));
            domainSwitches.addElement(domain, ch);
            domainSwitches.decorateElement(ch, blacklistedDomains.includes(domain));
          });
          return el;
        })());

        // Mark linked post.
        if (document.location.hash.slice(1) === post.getAttribute('id')) {
          post.classList.add('thread-tools-linked-post');
          postAnchorElement.parentElement.appendChild((function () {
            const label = document.createElement('div');
            const container = document.createElement('div');
            label.classList.add('thread-tools-linked-post-id-label');
            label.innerHTML = '#' + post.getAttribute('id');
            container.classList.add('thread-tools-linked-post-id-label-container');
            container.appendChild(label);
            return container;
          })());
        }

        if (contentRemoved) {
          // Refresh label that shows up to indicate that a refresh is needed to apply changes.
          postCountElement.parentElement.prepend((function () {
            const el = document.createElement('span')
            el.innerHTML = 'Removed';
            el.classList.add('post-thead-label');
            el.classList.add('removed');
            return el;
          })());
        }
      }
    });

  // Toggle buttons for multiple posts at once.
  const threadToolsMenu = document.querySelector('#threadtools_menu tbody');
  if (threadToolsMenu !== null) {
    const createThreadToolsMenuElement = function (autoFoldedElements, caption, nothingCaption) {
      const labels = ['►', '▼'];
      const tr = document.createElement('tr');
      tr.setAttribute('data-feature-multi-toggle', true);
      const td = document.createElement('td');
      const toggle = document.createElement('a');
      toggle.innerHTML = caption;
      const icon = document.createElement('span');
      icon.innerHTML = labels[0];
      icon.style.padding = '0 .7em';
      const onMouseOver = function () {
        td.setAttribute('class', 'vbmenu_hilite vbmenu_hilite_alink');
        td.style.cursor = 'pointer';
      };
      const onMouseOut = function () {
        td.setAttribute('class', 'vbmenu_option vbmenu_option_alink');
        td.style.cursor = 'default';
      };
      onMouseOut();
      if (autoFoldedElements.length) {
        td.addEventListener('mouseover', onMouseOver);
        td.addEventListener('mouseout', onMouseOut);
        toggle.setAttribute('data-state', 0);
        toggle.addEventListener('click', function () {
          const newState = (parseInt(toggle.getAttribute('data-state')) + 1) % labels.length;
          autoFoldedElements.forEach(function (el) {
            expandToggles.decorateElement(el, newState === 1);
          });
          toggle.setAttribute('data-state', newState);
          icon.innerHTML = labels[newState];
        });
        const threadToolsButton = document.querySelector('#threadtools');
        if (threadToolsButton !== null) {
          threadToolsButton.classList.add('thread-tools-have-additions')
        }
      } else {
        toggle.innerHTML += ' (' + nothingCaption + ')';
        toggle.style.opacity = '0.7';
      }
      toggle.prepend(icon);
      td.appendChild(toggle);
      tr.appendChild(td);
      return tr;
    };

    threadToolsMenu.appendChild(createThreadToolsMenuElement(
      expandToggles.filter('a', function (element) {
        return element.getAttribute('data-initial-state') === '0';
      }),
      'Toggle all auto-hidden posts',
      'nothing hidden'
    ));

    threadToolsMenu.appendChild(createThreadToolsMenuElement(
      expandToggles.filter('a', function (element) {
        if (element.getAttribute('data-initial-state') === '0') {
          const statusImage = element.closest('tr').querySelector('td a[name^=post] img.inlineimg');
          if (statusImage !== null) {
            return statusImage.getAttribute('alt') === 'Unread';
          }
        }
        return false;
      }),
      'Toggle unread auto-hidden posts',
      document.querySelector('a[name=newpost]') !== null ? 'nothing hidden' : 'no unread posts',
    ));

    // New feature notifier.
    const isMultiToggleNewFeature = GM_getValue('featureMultiToggleSeen', false);
    if (isMultiToggleNewFeature === false) {
      const threadToolsButton = document.querySelector('#threadtools');
      if (threadToolsButton !== null) {
        threadToolsMenu.querySelectorAll('tr[data-feature-multi-toggle]').forEach(function (tr) {
          const el = document.createElement('span')
          el.innerHTML = 'new';
          el.classList.add('post-thead-label');
          tr.querySelector(':scope > td').appendChild(el);
        });
        threadToolsButton.addEventListener('click', function () {
          GM_setValue('featureMultiToggleSeen', true);
          if (threadToolsButton.classList.contains('thread-tools-have-additions-feature')) {
            threadToolsButton.classList.remove('thread-tools-have-additions-feature');
          }
        });
        if (threadToolsButton.classList.contains('thread-tools-have-additions')) {
          threadToolsButton.classList.add('thread-tools-have-additions-feature')
        }
      }
    }
  }
};

GM_addStyle(`
  @-webkit-keyframes BackgroundPositionAnimation {
    0%{background-position:0% 50%}
    50%{background-position:100% 50%}
    100%{background-position:0% 50%}
  }
  @-moz-keyframes BackgroundPositionAnimation {
    0%{background-position:0% 50%}
    50%{background-position:100% 50%}
    100%{background-position:0% 50%}
  }
  @keyframes BackgroundPositionAnimation {
    0%{background-position:0% 50%}
    50%{background-position:100% 50%}
    100%{background-position:0% 50%}
  }
  .thread-tools-have-additions {
    background: #2c539e;
    transition: background 300ms linear;
  }
  .thread-tools-have-additions-feature {
    background: linear-gradient(270deg, #f2a304, #314ff5);
    background-size: 400% 400%;
    -webkit-animation: BackgroundPositionAnimation 30s ease infinite;
    -moz-animation: BackgroundPositionAnimation 30s ease infinite;
    animation: BackgroundPositionAnimation 30s ease infinite;
  }
  .post-thead-label {
    background: white;
    border-radius: 1px;
    color: #0b198c;
    font-weight: bold;
    margin: 0 3px;
    padding: 1px 3px;
  }
  .post-thead-label.removed {
    color: white;
    background: red;
  }
  .thead .post-thead-label.button:hover {
    color: #0b198c;
  }
  .thread-tools-linked-post {
    border-width: 2px;
    margin: -2px;
    width: calc(100% + 4px);
  }
  .thread-tools-linked-post-id-label  {
    background: #0B198C;
    margin-top: -7px;
    cursor: default;
    padding: 3px;
  }
  .thread-tools-linked-post-id-label-container {
    display: none;
    margin-left: 15px;
    vertical-align: top;
  }
  .thread-tools-linked-post-id-label-container + .thread-tools-linked-post-id-label-container {
    margin-left: 2px;
  }
  .thread-tools-linked-post:hover .thread-tools-linked-post-id-label-container {
    display: inline-block;
  }
`);

decoratePosts();