- // ==UserScript==
- // @name Reddit expand media and comments
- // @description Shows pictures and some videos right after the link, loads and expands comment threads.
-
- // @version 0.2.1
-
- // @author wOxxOm
- // @namespace wOxxOm.scripts
- // @license MIT License
-
- // @match *://*.reddit.com/*
-
- // @grant GM_addStyle
- // @grant GM_xmlhttpRequest
-
- // @connect imgur.com
- // @connect gfycat.com
- // @connect streamable.com
- // @connect instagram.com
- // @connect ibb.co
- // @connect prntscr.com
- // @connect prnt.sc
- // ==/UserScript==
-
- 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 = location.hostname.startsWith('old.') ? 500 : 100;
-
- const RULES = [{
- r: /^https?:\/\/(i\.)?imgur\.com\/(?:a|gallery)\/(\w+)$/i,
- s: 'https://imgur.com/ajaxalbums/getimages/$2/hit.json?all=true',
- q: json =>
- Array.from(((json || {}).data || {}).images || [])
- .map(img => img && `https://i.imgur.com/${img.hash}${img.ext}`),
- }, {
- r: /^https?:\/\/(i\.)?imgur\.com\/\w+$/i,
- q: 'link[rel="image_src"], meta[name="twitter:player:stream"]',
- }, {
- r: /^https?:\/\/streamable\.com\/.+/i,
- q: 'video',
- }, {
- r: /^https?:\/\/gfycat\.com\/.+/i,
- q: 'source[src*=".webm"]',
- }, {
- r: /^https?:\/\/(www\.)?instagram\.com\/p\/[^/]+\/?$/i,
- q: 'meta[property="og:image"]',
- }, {
- r: /^https?:\/\/ibb\.co\/\w+$/i,
- q: 'meta[property="og:image"]',
- }, {
- r: /^https?:\/\/prntscr\.com\/(\w+)$/i,
- s: 'https://prnt.sc/$1',
- q: 'meta[property="og:image"]',
- xhr: true,
- }, {
- r: /^https?:\/\/prnt\.sc\/(\w+)$/i,
- q: 'meta[property="og:image"]',
- xhr: true,
- }, {
- r: /\.gifv(\?.*)?$/i,
- s: '.mp4',
- }, {
- // 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 = [];
-
- 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 = false;
- 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;
- if (node.localName === 'a') {
- var rule = findMatchingRule(node);
- if (rule)
- items.push(rule);
- continue;
- }
- if (!node.firstElementChild)
- continue;
- var aa = node.getElementsByTagName('a');
- for (var k = 0, a; (a = aa[k++]);) {
- const data = findMatchingRule(a);
- if (data)
- items.push(data);
- }
- }
- }
- if (someElementsAdded)
- debounce(observeShowMore);
- if (items.length)
- setTimeout(maybeExpand, 0, items);
- }
-
- function onScroll(entries, observer) {
- for (const e of entries) {
- let el = e.target;
- if (el.localName === 'ins') {
- toggleAttribute(el.parentNode, OVERFLOW_ATTR, !e.isIntersecting);
- continue;
- }
- if (!e.isIntersecting) {
- if (!el.dataset.src && el[GM_info.script.name]) {
- delete el[GM_info.script.name];
- el.dataset.src = el.src;
- el.removeAttribute('src');
- el.removeEventListener('load', unobserveOnLoad);
- }
- continue;
- }
- const isImage = el.localName === 'img';
- if (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);
- }
- }
-
- function findMatchingRule(a) {
- var url = a.href;
- for (var i = 0; i < RULES.length; i++) {
- var rule = RULES[i];
- var r = rule.r;
- if (typeof r === 'string') {
- if (!url.includes(r))
- continue;
- } else {
- if (!r.test(url))
- continue;
- var s = rule.s;
- if (s)
- url = url.replace(r, s);
- }
- return {
- a,
- url,
- q: rule.q,
- xhr: rule.xhr,
- };
- }
- }
-
- function maybeExpand(items) {
- for (const item of items) {
- const {a, q} = 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 {
- (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);
- const 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);
- }
- observer.observe(el);
- return !isAlbum && el;
- }
-
- async function expandRemote(item) {
- const {url, q} = 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 q) {
- case 'string': {
- if (!isJSON)
- expandRemoteFromSelector(doc, item);
- return;
- }
- case 'function': {
- let urls = await q(doc, r.response);
- if (urls && urls.length) {
- urls = Array.isArray(urls) ? urls : [urls];
- expandFromUrls(urls, item);
- }
- return;
- }
- }
- }
-
- async function expandRemoteFromSelector(doc, {q, xhr, url, a}) {
- if (!doc)
- return;
- const el = doc.querySelector(q);
- if (!el)
- return;
- let imageUrl = el.href || el.src || el.content;
- if (!imageUrl)
- return;
- if (xhr)
- imageUrl = await downloadAsBase64({imageUrl, url});
- if (imageUrl)
- expand({a, url: imageUrl});
- }
-
- function expandFromUrls(urls, {a, url}) {
- 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() {
- 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 debounce(fn, timeout = 0, ...args) {
- clearTimeout(fn.__timeout);
- fn.__timeout = setTimeout(fn, timeout, ...args);
- }
-
- 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];
- }