- // ==UserScript==
- // @name Reddit expand media and comments
- // @description Shows pictures and some videos right after the link, loads and expands comment threads.
-
- // @version 0.3.6
-
- // @author wOxxOm
- // @namespace wOxxOm.scripts
- // @license MIT License
-
- // @match *://*.reddit.com/*
-
- // @grant GM_addStyle
- // @grant GM_xmlhttpRequest
-
- // @connect imgur.com
- // @connect gfycat.com
- // @connect gyazo.com
- // @connect streamable.com
- // @connect instagram.com
- // @connect ibb.co
- // @connect prntscr.com
- // @connect prnt.sc
- // @connect images.app.goo.gl
- // @connect www.google.com
- // @connect gstatic.com
- // ==/UserScript==
-
- 'use strict';
-
- const isOldReddit = !!unsafeWindow.reddit;
-
- (!isOldReddit || /^\/(user|([^/]+\/){2}comments)\//.test(location.pathname)) && (() => {
- /* eslint indent: [error, 2, {outerIIFEBody:0, SwitchCase:1}] */
-
- const CLASS = 'reddit-inline-media';
- const CLASS_ALBUM = CLASS + '-album';
- const OVERFLOW_ATTR = 'data-overflow';
- const MORE_SELECTOR = '[id^="moreComments-"] p, .morecomments a';
- const REQUEST_THROTTLE_MS = isOldReddit ? 500 : 100;
-
- const RULES = [{
- u: '.gstatic.com/images?',
- }, {
- 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"]',
- }, {
- u: '//streamable.com/',
- r: /\.com\/.+/,
- q: 'video',
- }, {
- u: '//gfycat.com/',
- r: /.com\/.+/,
- q: 'source[src*=".webm"]',
- }, {
- u: '//gyazo.com/',
- r: /\.com\/\w{32,}/,
- q: 'meta[name="twitter:image"]',
- xhr: true,
- }, {
- u: [
- 'instagram.com/p/',
- '//ibb.co/',
- '//images.app.goo.gl/',
- ],
- q: 'meta[property="og:image"]',
- }, {
- u: '//prntscr.com/',
- r: /\.com\/(\w+)$/i,
- s: 'https://prnt.sc/$1',
- q: 'meta[property="og:image"]',
- xhr: true,
- }, {
- u: '//prnt.sc/',
- r: /\.sc\/\w+$/i,
- q: 'meta[property="og:image"]',
- 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',
- }, {
- // keep this one at the end of the list
- r: /\.(jpe?g|png|gif|webm|mp4)(\?.*)?$/i,
- }];
-
- // language=CSS
- GM_addStyle(`
- .${CLASS} {
- max-width: 100%;
- display: block;
- }
- .${CLASS}[data-src] {
- padding-top: 400px;
- }
- .${CLASS}:hover {
- outline: 2px solid #3bbb62;
- }
- .${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 isChrome = navigator.userAgent.includes('Chrom');
- const more = [];
- const toStop = new Set();
-
- let scrollObserver = lazyCreateObserver(onScroll, {rootMargin: '200% 0px'},
- obs => (scrollObserver = obs));
-
- let albumObserver = lazyCreateObserver(onScroll, {rootMargin: '200px 0px'},
- obs => (albumObserver = obs));
-
- new MutationObserver(onMutation)
- .observe(document.body, {subtree: true, childList: true});
-
- onMutation([{
- addedNodes: [document.body],
- }]);
-
- function onMutation(mutations) {
- var items = [];
- var someElementsAdded;
- for (var i = 0, m; (m = mutations[i++]);) {
- for (var j = 0, added = m.addedNodes, node; (node = added[j++]);) {
- if (!node.localName)
- continue;
- someElementsAdded = true;
- var aa = node.localName === 'a' ? [node] : node.getElementsByTagName('a');
- for (var k = 0, a; (a = aa[k++]);) {
- var data = findMatchingRule(a);
- if (data && !(isOldReddit && a.closest('.side')))
- 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 < -200 || rect.top > innerHeight + 200) &&
- 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 (isImage && el.dataset.src || 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)
- requestAnimationFrame(stopOffscreenImages);
- }
-
- 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) {
- const {u} = rule;
- if (u && !(Array.isArray(u) ? u.find(includedInThis, url) : url.includes(u)))
- 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,
- url,
- rule,
- };
- }
- }
-
- 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] &&
- (
- !text.startsWith('http') ||
- !text.endsWith('...') ||
- !/^https?:\/\/\S+?\.{3}$/.test(text)
- ) &&
- !a.closest(
- '.scrollerItem,' +
- '[contenteditable="true"],' +
- `a[href="${href}"] + * a[href="${href}"],` +
- `img[src="${href}"] + * a[href="${href}"]`) &&
- (
- // 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) {
- let el = isAlbum ? a.lastElementChild : a.nextElementSibling;
- if (!el || el.src !== url && el.dataset.src !== url) {
- const isVideo = /(webm|gifv|mp4)(\?.*)?$/i.test(url);
- el = document.createElement(isVideo ? 'video' : 'img');
- el.dataset.src = url;
- el.className = CLASS;
- a.insertAdjacentElement(isAlbum ? 'beforeEnd' : 'afterEnd', el);
- if (isVideo) {
- el.controls = true;
- el.preload = 'metadata';
- if (isChrome)
- el.addEventListener('click', playOnClick);
- }
- console.debug('expand:', el);
- observer.observe(el);
- }
- return !isAlbum && el;
- }
-
- async function expandRemote(item) {
- const {url, rule} = item;
- const r = await download(url);
- const isJSON = /^content-type:.*?json\s*$/mi.test(r.responseHeaders);
- const doc = isJSON ?
- tryJSONparse(r.response) :
- new DOMParser().parseFromString(r.response, 'text/html');
- switch (typeof rule.q) {
- case 'string': {
- if (!isJSON)
- expandRemoteFromSelector(doc, item);
- return;
- }
- case 'function': {
- let urls;
- try {
- urls = await rule.q(doc, r.response, 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) {
- 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')));
- }
- }
-
- function expandNextComment(el) {
- if (el)
- more.push(el);
- else
- more.shift();
- if (more.length === 1 || !el && more.length) {
- 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 playOnClick(event, el, wasPaused) {
- if (!el) {
- setTimeout(playOnClick, 0, event, this, this.paused);
- } else if (el.paused === wasPaused) {
- wasPaused ? el.play() : el.pause();
- }
- }
-
- function tryJSONparse(str) {
- try {
- return JSON.parse(str);
- } catch (e) {
- return undefined;
- }
- }
-
- function download(options) {
- if (typeof options === 'string')
- options = {url: options};
- return new Promise((resolve, reject) => {
- GM_xmlhttpRequest(Object.assign({
- method: 'GET',
- onload: resolve,
- onerror: reject,
- }, options));
- });
- }
-
- async function downloadAsBase64({imageUrl, url}) {
- let blob = (await download({
- url: imageUrl,
- headers: {
- 'Referer': url,
- },
- responseType: 'blob',
- })).response;
-
- 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 getMimeType(url) {
- const ext = (url.match(/\.(\w+)(\?.*)?$|$/)[1] || '').toLowerCase();
- return 'image/' + (ext === 'jpg' ? 'jpeg' : ext);
- }
-
- function toggleAttribute(el, name, state) {
- if (state && !el.hasAttribute(name))
- el.setAttribute(name, '');
- else
- el.removeAttribute(name);
- }
-
- function lazyCreateObserver(onIntersect, options, onCreate) {
- return new Proxy({}, {
- get(_target, k) {
- const observer = new IntersectionObserver(onIntersect, options);
- onCreate(observer);
- const v = observer[k];
- return typeof v === 'function' ? v.bind(observer) : v;
- },
- });
- }
-
- 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) {
- return this.includes(needle);
- }
-
- })();