Reddit expand media and comments

Shows pictures and some videos right after the link, loads and expands comment threads.

Versione datata 22/10/2020. Vedi la nuova versione l'ultima versione.

// ==UserScript==
// @name           Reddit expand media and comments
// @description    Shows pictures and some videos right after the link, loads and expands comment threads.
//
// @version        0.4.9
//
// @author         wOxxOm
// @namespace      wOxxOm.scripts
// @license        MIT License
//
// @match          *://*.reddit.com/*
//
// @grant          GM_addStyle
// @grant          GM_getValue
// @grant          GM_setValue
// @grant          GM_registerMenuCommand
// @grant          GM_xmlhttpRequest
//
// @connect        freeimage.host
// @connect        gfycat.com
// @connect        gstatic.com
// @connect        gyazo.com
// @connect        ibb.co
// @connect        iili.io
// @connect        images.app.goo.gl
// @connect        imgshare.io
// @connect        imgur.com
// @connect        instagram.com
// @connect        pasteall.org
// @connect        pasteboard.co
// @connect        postimg.cc
// @connect        prnt.sc
// @connect        prntscr.com
// @connect        streamable.com
// @connect        www.google.com
// ==/UserScript==

'use strict';

const isOldReddit = !!unsafeWindow.reddit;
(!isOldReddit || /^\/(user|([^/]+\/){2}comments)\//.test(location.pathname)) && (() => {
/* eslint indent: [error, 2, {outerIIFEBody:0, SwitchCase:1}] */

//#region Init

let imgurQuality = GM_getValue('imgurQuality', 'h');
GM_registerMenuCommand('Configure', configure);

const CLASS = 'reddit-inline-media';
const CLASS_ALBUM = CLASS + '-album';
const CLASS_SMALL = CLASS + '-small'; // for user profiles where pics are often repeated
const OVERFLOW_ATTR = 'data-overflow';
const MORE_SELECTOR = '[id^="moreComments-"] p, .morecomments a';
const REQUEST_THROTTLE_MS = isOldReddit ? 500 : 100;

const META_OG_IMG = 'meta[property="og:image"]';
const META_TW_IMG = 'meta[name="twitter:image"]';
const RULES = [{
  /* imgur **********************************/
  u: [
    'imgur.com/a/',
    'imgur.com/gallery/',
  ],
  r: /(a|gallery)\/(\w+)(#\w+)?$/,
  s: 'https://imgur.com/ajaxalbums/getimages/$2/hit.json?all=true',
  q: json =>
    json.data.images.map(img =>
      img && `https://i.imgur.com/${img.hash}${img.ext}`),
}, {
  u: 'imgur.com/',
  r: /\.com\/\w+(\?.*)?$/,
  q: `link[rel="image_src"], meta[name="twitter:player:stream"], ${META_TW_IMG}, ${META_OG_IMG}`,
}, {
  /* generic **********************************/
  u: [
    '//freeimage.host/i/',
    '//imgshare.io/image/',
    '//prnt.sc/',
  ],
  q: META_OG_IMG,
  xhr: true,
}, {
  u: [
    '//gyazo.com/',
    '//pasteboard.co/',
  ],
  q: META_TW_IMG,
  xhr: true,
}, {
  u: [
    'instagram.com/p/',
    '//ibb.co/',
    '//images.app.goo.gl/',
    '//postimg.cc/',
  ],
  q: META_OG_IMG,
}, {
  /* individual sites **********************************/
  u: '.gstatic.com/images?',
}, {
  u: '//streamable.com/',
  q: 'video',
}, {
  u: '//gfycat.com/',
  q: 'source[src*=".webm"]',
}, {
  u: '//pasteall.org/',
  q: '.center-fit',
}, {
  u: '//prntscr.com/',
  r: /\.com\/(\w+)$/i,
  s: 'https://prnt.sc/$1',
  q: META_OG_IMG,
  xhr: true,
}, {
  u: [
    '//youtu.be/',
    '//youtube.com/',
    '//www.youtube.com/',
  ],
  r: /\/\/[^/]+?(?:\.be\/|\.com\/.*?[&?/]v[=/])([^&?/#]+)/,
  s: 'https://i.ytimg.com/vi/$1/default.jpg',
}, {
  u: '//pbs.twimg.com/media/',
  r: /.+?\?format=\w+/,
}, {
  u: '.gifv',
  r: /(.+?)\.gifv(\?.*)?$/i,
  s: '$1.mp4$2',
}];
// last rule: direct images
RULES.push({
  r: /\.(jpe?g|png|gif|webm|mp4)(\?.*)?$/i,
});
for (const rule of RULES)
  if (rule.u && !Array.isArray(rule.u))
    rule.u = [rule.u];

// language=CSS
GM_addStyle(`
  .${CLASS} {
    max-width: 100%;
    display: block;
  }
  .${CLASS}[data-src] {
    padding-top: 400px;
  }
  .${CLASS}:hover {
    outline: 2px solid #3bbb62;
  }
  .${CLASS}.${CLASS_SMALL} {
    max-height: 25vh;
  }
  .${CLASS_ALBUM} {
    overflow-y: auto;
    max-height: calc(100vh - 100px);
    margin: .5em 0;
  }
  .${CLASS_ALBUM}[${OVERFLOW_ATTR}] {
    -webkit-mask-image: linear-gradient(white 75%, transparent);
    mask-image: linear-gradient(white 75%, transparent);
  }
  .${CLASS_ALBUM}[${OVERFLOW_ATTR}]:hover {
    -webkit-mask-image: none;
    mask-image: none;
  }
  .${CLASS_ALBUM} > :nth-child(n + 2) {
    margin-top: 1em;
  }
`);

const more = [];
const toStop = new Set();
const menu = {
  get el() {
    return document.querySelector(isOldReddit ? '.drop-choices.inuse' : '[role="menu"]');
  },
  resolve: null,
  observer: new MutationObserver(() => {
    const {el} = menu;
    if (!el || isOldReddit && !el.classList.contains('inuse')) {
      menu.observer.disconnect();
      menu.resolve();
    }
  }),
  observerConfig: isOldReddit ?
    {attributes: true, attributeFilter: ['class']} :
    {childList: true},
};
const scrollObserver = new IntersectionObserver(onScroll, {rootMargin: '200% 0px'});
const albumObserver = new IntersectionObserver(onScroll, {rootMargin: '200px 0px'});

new MutationObserver(onMutation)
  .observe(document.body, {subtree: true, childList: true});

onMutation([{
  addedNodes: [document.body],
}]);

//#endregion

function onMutation(mutations) {
  const items = [];
  let someElementsAdded;
  for (const {addedNodes} of mutations) {
    for (const node of addedNodes) {
      if (!node.localName)
        continue;
      someElementsAdded = true;
      for (const a of node.localName === 'a' ? [node] : node.getElementsByTagName('a')) {
        if (isOldReddit && a.closest('.side, .title'))
          continue;
        const data = findMatchingRule(a);
        if (data)
          items.push(data);
      }
    }
  }
  if (someElementsAdded && !observeShowMore.timer)
    observeShowMore.timer = setTimeout(observeShowMore, 500);
  if (items.length)
    setTimeout(maybeExpand, 0, items);
}

function onScroll(entries, observer) {
  const stoppingScheduled = toStop.size > 0;
  for (const e of entries) {
    let el = e.target;
    if (el.localName === 'ins') {
      toggleAttribute(el.parentNode, OVERFLOW_ATTR, !e.isIntersecting);
      continue;
    }
    if (!e.isIntersecting) {
      const rect = e.boundingClientRect;
      if ((rect.bottom < -innerHeight * 2 || rect.top > innerHeight * 2) &&
          el.src && !el.dataset.src && el[GM_info.script.name])
        toStop.add(el);
      continue;
    }
    if (stoppingScheduled)
      toStop.delete(el);
    const isImage = el.localName === 'img';
    if (el.dataset.src && (isImage || el.localName === 'video')) {
      el.src = el.dataset.src;
      el[GM_info.script.name] = {observer};
      el.addEventListener(isImage ? 'load' : 'loadedmetadata', unobserveOnLoad);
      delete el.dataset.src;
      continue;
    }
    if (el.localName === 'a') {
      // switch to an unfocusable element to prevent the link
      // from stealing focus and scrolling the view
      const el2 = document.createElement('span');
      el2.setAttribute('onclick', el.getAttribute('onclick'));
      el2.setAttribute('id', el.id);
      el.parentNode.replaceChild(el2, el);
      el = el2;
    }
    expandNextComment(el);
  }
  if (!stoppingScheduled && toStop.size)
    setTimeout(stopOffscreenImages, 100);
}

function stopOffscreenImages() {
  console.debug('stopOffscreenImages:', [...toStop]);
  for (const el of toStop) {
    if (el.naturalWidth || el.videoWidth)
      continue;
    delete el[GM_info.script.name];
    el.dataset.src = el.src;
    el.removeAttribute('src');
    el.removeEventListener('load', unobserveOnLoad);
  }
  toStop.clear();
}

function findMatchingRule(a) {
  let url = a.href;
  for (const rule of RULES) {
    if (rule.u && !rule.u.find(includedInThis, url))
      continue;
    const {r} = rule;
    const m = !r || url.match(r);
    if (!m)
      continue;
    if (r && rule.s)
      url = url.slice(0, m.index + m[0].length).replace(r, rule.s).slice(m.index);
    return {a, rule, url};
  }
}

function maybeExpand(items) {
  for (const item of items) {
    const {a, rule} = item;
    const {href} = a;
    const text = a.textContent.trim();
    if (
      text &&
      !a.getElementsByTagName('img')[0] &&
      !/^https?:\/\/\S+?\.{3}$/.test(text) &&
      !a.closest(
        '.scrollerItem,' +
        '[contenteditable="true"],' +
        `a[href="${href}"] + * a[href="${href}"],` +
        `img[src="${href}"] + * a[href="${href}"]`) &&
      (
        isOldReddit ||
        // don't process insides of a post except for its text
        !a.closest('[data-test-id="post-content"]') ||
        a.closest('[data-click-id="text"]')
      )
    ) {
      try {
        (rule.q ? expandRemote : expand)(item);
      } catch (e) {
        // console.debug(e, item);
      }
    }
  }
}

function expand({a, url = a.href, isAlbum}, observer = scrollObserver) {
  const isVideo = /(webm|gifv|mp4)(\?.*)?$/i.test(url);
  if (!isVideo && url.includes('://i.imgur.com/'))
    url = setImgurQuality(url);
  let el = isAlbum ? a.lastElementChild : a.nextElementSibling;
  if (!el || el.src !== url && el.dataset.src !== url) {
    el = document.createElement(isVideo ? 'video' : 'img');
    el.dataset.src = url;
    el.className = CLASS + (location.pathname.startsWith('/user/') ? ' ' + CLASS_SMALL : '');
    a.insertAdjacentElement(isAlbum ? 'beforeEnd' : 'afterEnd', el);
    if (isVideo) {
      el.controls = true;
      el.preload = 'metadata';
    }
    observer.observe(el);
  }
  return !isAlbum && el;
}

async function expandRemote(item) {
  const {url, rule} = item;
  const r = await download(url);
  const text = r.response;
  const isJSON = /^content-type:.*?json\s*$/mi.test(r.responseHeaders);
  const doc = isJSON ? tryJSONparse(text) : parseDoc(text, url);
  switch (typeof rule.q) {
    case 'string': {
      if (!isJSON)
        expandRemoteFromSelector(doc, item);
      return;
    }
    case 'function': {
      let urls;
      try {
        urls = await rule.q(doc, text, item);
      } catch (e) {}
      if (urls && urls.length) {
        urls = Array.isArray(urls) ? urls : [urls];
        expandFromUrls(urls, item);
      }
      return;
    }
  }
}

async function expandRemoteFromSelector(doc, {rule, url, a}) {
  if (!doc)
    return;
  const el = doc.querySelector(rule.q);
  if (!el)
    return;
  let imageUrl = el.href || el.src || el.content;
  if (!imageUrl)
    return;
  if (rule.xhr)
    imageUrl = await downloadAsBase64({imageUrl, url});
  if (imageUrl)
    expand({a, url: imageUrl});
}

function expandFromUrls(urls, {a}) {
  let observer;
  const isAlbum = urls.length > 1;
  if (isAlbum) {
    if (a.nextElementSibling && a.nextElementSibling.classList.contains(CLASS_ALBUM))
      return;
    observer = albumObserver;
    a = a.insertAdjacentElement('afterEnd', document.createElement('div'));
    a.className = CLASS_ALBUM;
  }
  for (const url of urls) {
    if (url)
      a = expand({a, url, isAlbum}, observer) || a;
  }
  if (isAlbum) {
    new IntersectionObserver(onScroll, {root: a})
      .observe(a.appendChild(document.createElement('ins')));
  }
}

async function expandNextComment(el) {
  if (el)
    more.push(el);
  else
    more.shift();
  if (more.length === 1 || !el && more.length) {
    if (menu.el) {
      await new Promise(resolve => {
        menu.resolve = resolve;
        menu.observer.observe(isOldReddit ? menu.el : document.body, menu.observerConfig);
      });
    }
    more[0].dispatchEvent(new MouseEvent('click', {bubbles: true}));
    setTimeout(expandNextComment, REQUEST_THROTTLE_MS);
  }
}

function observeShowMore() {
  observeShowMore.timer = 0;
  if (document.querySelector(MORE_SELECTOR)) {
    for (const el of document.querySelectorAll(MORE_SELECTOR)) {
      scrollObserver.observe(el);
    }
  }
}

function tryJSONparse(str) {
  try {
    return JSON.parse(str);
  } catch (e) {}
}

function download(options) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest(Object.assign({
      method: 'GET',
      onload: resolve,
      onerror: reject,
    }, typeof options === 'string' ? {url: options} : options));
  });
}

async function downloadAsBase64({imageUrl, url}) {
  let {response: blob} = await download({
    url: imageUrl,
    headers: {
      Referer: url,
    },
    responseType: 'blob',
  });

  if (blob.type !== getMimeType(imageUrl))
    blob = blob.slice(0, blob.size, getMimeType(imageUrl));

  return new Promise(resolve => {
    Object.assign(new FileReader(), {
      onload: e => resolve(e.target.result),
    }).readAsDataURL(blob);
  });
}

function parseDoc(text, url) {
  const doc = new DOMParser().parseFromString(text, 'text/html');
  if (!doc.querySelector('base'))
    doc.head.appendChild(doc.createElement('base')).href = url;
  return doc;
}

function getMimeType(url) {
  const ext = (url.match(/\.(\w+)(\?.*)?$|$/)[1] || '').toLowerCase();
  return 'image/' + (ext === 'jpg' ? 'jpeg' : ext);
}

function toggleAttribute(el, name, state) {
  const oldState = el.hasAttribute(name);
  if (state && !oldState)
    el.setAttribute(name, '');
  else if (!state && oldState)
    el.removeAttribute(name);
}

function unobserveOnLoad() {
  this.removeEventListener('load', unobserveOnLoad);
  const {observer} = this[GM_info.script.name] || {};
  if (observer)
    observer.unobserve(this);
  delete this[GM_info.script.name];
}

function includedInThis(needle) {
  const i = this.indexOf(needle);
  // URL should have something after `u` part
  return i >= 0 && this.length > i + needle.length;
}

function setImgurQuality(url) {
  const i = url.lastIndexOf('/') + 1;
  const j = url.lastIndexOf('.');
  const ext = url.slice(j);
  return url.slice(0, i + 7) + (i && j > i && !/webm|mp4/.test(ext) ? imgurQuality : '') + '.jpg';
}

function configure() {
  const q = prompt(
    'imgur quality suffix: h, l, m, t, b, s (empty = no change,\n' +
    'letters = huge, large, medium, thumbnail, big square, small square)',
    imgurQuality);
  if (q && q !== imgurQuality) {
    imgurQuality = q;
    GM_setValue('imgurQuality', q);
    const selector = `.${CLASS}!, .${CLASS_SMALL}!, .${CLASS}@, .${CLASS_SMALL}@`
      .replace(/!/g, '[src*="imgur.com"]').replace(/@/g, '[data-src*="imgur.com"]');
    for (const el of document.querySelectorAll(selector)) {
      const src = el.src || el.dataset.src;
      const newSrc = setImgurQuality(src);
      if (src !== newSrc)
        el.setAttribute(el.src ? 'src' : 'data-src', newSrc);
    }
  }
}
})();