- // ==UserScript==
- // @name Reddit expand media and comments
- // @description Shows pictures and some videos right after the link, loads and expands comment threads.
- // @icon https://www.redditstatic.com/desktop2x/img/favicon/apple-icon-72x72.png
- //
- // @version 1.0.14
- //
- // @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
- //
- // @grant GM.addStyle
- // @grant GM.getValue
- // @grant GM.setValue
- // @grant GM.registerMenuCommand
- // @grant GM.xmlHttpRequest
- //
- // @connect freeimage.host
- // @connect gfycat.com
- // @connect gph.is
- // @connect gstatic.com
- // @connect gyazo.com
- // @connect ibb.co
- // @connect iili.io
- // @connect images.app.goo.gl
- // @connect imgchest.com
- // @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 sakugabooru.com
- // @connect streamable.com
- // @connect tenor.com
- // @connect www.google.com
- // ==/UserScript==
-
- 'use strict';
-
- const isOldReddit = !!(unsafeWindow.wrappedJSObject || unsafeWindow).reddit;
- if (isOldReddit && !/^\/(user|([^/]+\/){2}comments)\//.test(location.pathname))
- return;
-
- //#region Init
-
- const $ = (sel, base = document) => base.querySelector(sel);
- const $$ = (sel, base = document) => base.querySelectorAll(sel);
- const stringifyConfig = c => (c.expandComments ? '+' : '-') + c.imgurQuality;
- const parseConfig = str => {
- const m = `${str || ''}`.match(/^\s*([-+])?\s*([a-z])?/i) || [];
- return {
- expandComments: m[1] !== '-',
- imgurQuality: (m[2] || 'h').toLowerCase(),
- };
- };
- const cfg = {};
- const gm = typeof GM !== 'undefined' ? GM : {
- getValue: k => Promise.resolve(GM_getValue(k)),
- setValue: (k, v) => Promise.resolve(GM_setValue(k, v)),
- addStyle: GM_addStyle,
- xmlHttpRequest: GM_xmlhttpRequest,
- };
- (gm.registerMenuCommand || GM_registerMenuCommand)('REM&C: 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, .deepthread 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/',
- '//gph.is/',
- '//ibb.co/',
- '//images.app.goo.gl/',
- '//postimg.cc/',
- '//tenor.com/view/',
- ],
- q: META_OG_IMG,
- }, {
- u: ['//imgchest.com/'],
- q: doc => [].map.call($$(META_OG_IMG, doc), el => el.content),
- }, {
- /* individual sites **********************************/
- u: '.gstatic.com/images?',
- }, {
- u: '//streamable.com/',
- q: 'video',
- }, {
- u: '//gfycat.com/',
- q: 'source[src*=".webm"], .actual-gif-image',
- }, {
- u: '//giphy.com/gifs/',
- r: /gifs\/([^/]+-)?(\w+)/,
- s: 'https://media.giphy.com/media/$2/giphy.gif',
- }, {
- u: '//pasteall.org/',
- q: '.center-fit',
- }, {
- u: '//prntscr.com/',
- r: /\.com\/(\w+)$/i,
- s: 'https://prnt.sc/$1',
- q: META_OG_IMG,
- xhr: true,
- }, {
- r: /^(https:\/\/www\.reddit\.com\/)gallery(\/\w+)$/i,
- s: '$1comments$2.json',
- q: json => Object.values(json[0].data.children[0].data.media_metadata)
- .map(v => v.s.u.replace(/&/g, '&')),
- }, {
- u: 'https://www.sakugabooru.com/post/show/',
- q: '#highres',
- }, {
- u: 'youtu',
- r: /\/\/(?:youtu\.be\/|(?:(?:www|m)\.)?youtube\.com\/(?:.*?[&?/]v[=/]|shorts\/))([^&?/#]+)/,
- s: 'https://i.ytimg.com/vi/$1/hqdefault.jpg',
- }, {
- u: '//pbs.twimg.com/media/',
- r: /.+?(\?format=|\.\w+:)\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 || (
- css => (document.head || document.documentElement)
- .appendChild(Object.assign(document.createElement('style'), {textContent: css}))
- ))(`
- .${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;
- }
- `);
-
- let pageUrl = location.pathname;
- let moreTimer;
- const observers = new Map();
- const more = [];
- const toStop = new Set();
- const menu = {
- get el() {
- return $(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: '150% 0px'});
-
- gm.getValue('imgurQuality').then(v => {
- Object.assign(cfg, parseConfig(v));
- onMutation([{
- addedNodes: [document.body],
- }]);
- new MutationObserver(onMutation)
- .observe(document.body, {subtree: true, childList: true});
- });
-
- //#endregion
-
- function onMutation(mutations) {
- if (pageUrl !== location.pathname) {
- pageUrl = location.pathname;
- observers.forEach(o => o.disconnect());
- observers.clear();
- stopOffscreenImages();
- }
- 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 (a.href.startsWith('https://www.reddit.com/r/') ||
- isOldReddit && a.closest('.side, .title, #header'))
- continue;
- const data = findMatchingRule(a);
- if (data)
- items.push(data);
- }
- }
- }
- if (someElementsAdded && !moreTimer && cfg.expandComments)
- moreTimer = setTimeout(observeShowMore, 500);
- if (items.length)
- setTimeout(maybeExpand, 0, items);
- }
-
- function onScroll(entries, observer) {
- const stoppingScheduled = toStop.size > 0;
- const expanders = [];
- for (const e of entries) {
- let el = e.target;
- if (el.localName === 'ins') {
- toggleAttribute(el.parentNode, OVERFLOW_ATTR, !e.isIntersecting);
- continue;
- }
- const rect = e.boundingClientRect;
- if (!e.isIntersecting) {
- if ((rect.bottom < -innerHeight * 2 || rect.top > innerHeight * 2) &&
- el.src && !el.dataset.src && observers.has(el))
- toStop.add(el);
- continue;
- } else if (el.classList.contains(CLASS_ALBUM)) {
- observer.unobserve(el);
- el.appendChild(document.createElement('ins'));
- const io = new IntersectionObserver(onScroll, {root: el});
- for (const c of el.children)
- io.observe(c);
- observers.set(el, io);
- 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.addEventListener(isImage ? 'load' : 'loadedmetadata', unobserveOnLoad);
- delete el.dataset.src;
- continue;
- }
- if (el.localName === 'a' && el.id) {
- // 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;
- }
- expanders[rect.top >= 0 && rect.bottom <= innerHeight ? 'unshift' : 'push'](el);
- }
- expanders.forEach(expandNextComment);
- if (!stoppingScheduled && toStop.size)
- setTimeout(stopOffscreenImages, 100);
- }
-
- function stopOffscreenImages() {
- for (const el of toStop) {
- if (el.naturalWidth || el.videoWidth)
- continue;
- el.dataset.src = el.src;
- el.removeAttribute('src');
- }
- 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}) {
- 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';
- }
- scrollObserver.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/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}) {
- const isAlbum = urls.length > 1;
- if (isAlbum) {
- if (a.nextElementSibling && a.nextElementSibling.classList.contains(CLASS_ALBUM))
- return;
- a = a.insertAdjacentElement('afterEnd', document.createElement('div'));
- a.className = CLASS_ALBUM;
- scrollObserver.observe(a);
- }
- for (const url of urls) {
- if (url)
- a = expand({a, url, isAlbum}) || a;
- }
- }
-
- 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);
- });
- }
- const a = more[0];
- if (a.href) {
- expandDeepThread(a);
- } else {
- a.dispatchEvent(new MouseEvent('click', {bubbles: true}));
- }
- a.removeAttribute('onclick');
- more.forEach((el, i) => {
- scrollObserver.unobserve(el);
- if (i) scrollObserver.observe(el);
- });
- more.length = 1;
- setTimeout(expandNextComment, REQUEST_THROTTLE_MS);
- }
- }
-
- async function expandDeepThread(a) {
- try {
- a.style.opacity = .25;
- const url = a.href;
- const doc = parseDoc(await (await fetch(url)).text(), url);
- const table = $('.sitetable.nestedlisting', doc);
- const thing = $('.thing', table);
- const oldThing = document.getElementById(thing.id);
- if (oldThing) {
- oldThing.replaceWith(thing);
- } else {
- table.classList.remove('nestedlisting');
- a.closest('.thing').replaceWith(table);
- }
- } catch (e) {}
- }
-
- function observeShowMore() {
- moreTimer = 0;
- if ($(MORE_SELECTOR)) {
- for (const el of $$(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(this.localName === 'img' ? 'load' : 'loadedmetadata', unobserveOnLoad);
- const io = observers.get(this);
- if (io) io.unobserve(this);
- }
-
- function includedInThis(needle) {
- const i = this.indexOf(needle);
- // URL should have something after `u` part if that ends with '/'
- return i >= 0 && (!this.endsWith('/') || 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, Math.min(i + 7, j)) + (i && j > i && !/webm|mp4/.test(ext) ? cfg.imgurQuality : '') + '.jpg';
- }
-
- function configure() {
- const str = stringifyConfig(cfg);
- const q = prompt(
- 'Toggle comment expansion and set imgur quality like +h or -b where ' +
- '+ or - toggles comment expansion and h, l, m, t, b, s sets imgur quality: ' +
- 'Huge, Large, Medium, Thumbnail, Big square, Small square, or no letter to use default.',
- str);
- if (q == null) return;
- const cfg2 = parseConfig(q);
- const str2 = stringifyConfig(cfg2);
- if (str2 === str) return;
- gm.setValue('imgurQuality', str2);
- if (cfg.imgurQuality !== cfg2.imgurQuality) {
- const selector = `.${CLASS}!, .${CLASS_SMALL}!, .${CLASS}@, .${CLASS_SMALL}@`
- .replace(/!/g, '[src*="imgur.com"]').replace(/@/g, '[data-src*="imgur.com"]');
- for (const el of $$(selector)) {
- const src = el.src || el.dataset.src;
- const newSrc = setImgurQuality(src);
- if (src !== newSrc)
- el.setAttribute(el.src ? 'src' : 'data-src', newSrc);
- }
- }
- if (cfg.expandComments !== cfg2.expandComments) {
- if (cfg2.expandComments) {
- observeShowMore();
- } else {
- clearTimeout(moreTimer);
- more.length = moreTimer = 0;
- }
- }
- Object.assign(cfg, cfg2);
- }