Reddit expand media and comments

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

  1. // ==UserScript==
  2. // @name Reddit expand media and comments
  3. // @description Shows pictures and some videos right after the link, loads and expands comment threads.
  4. // @icon https://www.redditstatic.com/desktop2x/img/favicon/apple-icon-72x72.png
  5. //
  6. // @version 1.0.14
  7. //
  8. // @author wOxxOm
  9. // @namespace wOxxOm.scripts
  10. // @license MIT License
  11. //
  12. // @match *://*.reddit.com/*
  13. //
  14. // @grant GM_addStyle
  15. // @grant GM_getValue
  16. // @grant GM_setValue
  17. // @grant GM_registerMenuCommand
  18. // @grant GM_xmlhttpRequest
  19. //
  20. // @grant GM.addStyle
  21. // @grant GM.getValue
  22. // @grant GM.setValue
  23. // @grant GM.registerMenuCommand
  24. // @grant GM.xmlHttpRequest
  25. //
  26. // @connect freeimage.host
  27. // @connect gfycat.com
  28. // @connect gph.is
  29. // @connect gstatic.com
  30. // @connect gyazo.com
  31. // @connect ibb.co
  32. // @connect iili.io
  33. // @connect images.app.goo.gl
  34. // @connect imgchest.com
  35. // @connect imgshare.io
  36. // @connect imgur.com
  37. // @connect instagram.com
  38. // @connect pasteall.org
  39. // @connect pasteboard.co
  40. // @connect postimg.cc
  41. // @connect prnt.sc
  42. // @connect prntscr.com
  43. // @connect sakugabooru.com
  44. // @connect streamable.com
  45. // @connect tenor.com
  46. // @connect www.google.com
  47. // ==/UserScript==
  48.  
  49. 'use strict';
  50.  
  51. const isOldReddit = !!(unsafeWindow.wrappedJSObject || unsafeWindow).reddit;
  52. if (isOldReddit && !/^\/(user|([^/]+\/){2}comments)\//.test(location.pathname))
  53. return;
  54.  
  55. //#region Init
  56.  
  57. const $ = (sel, base = document) => base.querySelector(sel);
  58. const $$ = (sel, base = document) => base.querySelectorAll(sel);
  59. const stringifyConfig = c => (c.expandComments ? '+' : '-') + c.imgurQuality;
  60. const parseConfig = str => {
  61. const m = `${str || ''}`.match(/^\s*([-+])?\s*([a-z])?/i) || [];
  62. return {
  63. expandComments: m[1] !== '-',
  64. imgurQuality: (m[2] || 'h').toLowerCase(),
  65. };
  66. };
  67. const cfg = {};
  68. const gm = typeof GM !== 'undefined' ? GM : {
  69. getValue: k => Promise.resolve(GM_getValue(k)),
  70. setValue: (k, v) => Promise.resolve(GM_setValue(k, v)),
  71. addStyle: GM_addStyle,
  72. xmlHttpRequest: GM_xmlhttpRequest,
  73. };
  74. (gm.registerMenuCommand || GM_registerMenuCommand)('REM&C: Configure', configure);
  75.  
  76. const CLASS = 'reddit-inline-media';
  77. const CLASS_ALBUM = CLASS + '-album';
  78. const CLASS_SMALL = CLASS + '-small'; // for user profiles where pics are often repeated
  79. const OVERFLOW_ATTR = 'data-overflow';
  80. const MORE_SELECTOR = '[id^="moreComments-"] p, .morecomments a, .deepthread a';
  81. const REQUEST_THROTTLE_MS = isOldReddit ? 500 : 100;
  82.  
  83. const META_OG_IMG = 'meta[property="og:image"]';
  84. const META_TW_IMG = 'meta[name="twitter:image"]';
  85. const RULES = [{
  86. /* imgur **********************************/
  87. u: [
  88. 'imgur.com/a/',
  89. 'imgur.com/gallery/',
  90. ],
  91. r: /(a|gallery)\/(\w+)\/?([#.]\w+)?$/,
  92. s: 'https://imgur.com/ajaxalbums/getimages/$2/hit.json?all=true',
  93. q: json =>
  94. json.data.images.map(img =>
  95. img && `https://i.imgur.com/${img.hash}${img.ext}`),
  96. }, {
  97. u: 'imgur.com/',
  98. r: /\.com\/\w+\/?(\?.*)?$/,
  99. q: `link[rel="image_src"], meta[name="twitter:player:stream"], ${META_TW_IMG}, ${META_OG_IMG}`,
  100. }, {
  101. /* generic **********************************/
  102. u: [
  103. '//freeimage.host/i/',
  104. '//imgshare.io/image/',
  105. '//prnt.sc/',
  106. ],
  107. q: META_OG_IMG,
  108. xhr: true,
  109. }, {
  110. u: [
  111. '//gyazo.com/',
  112. '//pasteboard.co/',
  113. ],
  114. q: META_TW_IMG,
  115. xhr: true,
  116. }, {
  117. u: [
  118. 'instagram.com/p/',
  119. '//gph.is/',
  120. '//ibb.co/',
  121. '//images.app.goo.gl/',
  122. '//postimg.cc/',
  123. '//tenor.com/view/',
  124. ],
  125. q: META_OG_IMG,
  126. }, {
  127. u: ['//imgchest.com/'],
  128. q: doc => [].map.call($$(META_OG_IMG, doc), el => el.content),
  129. }, {
  130. /* individual sites **********************************/
  131. u: '.gstatic.com/images?',
  132. }, {
  133. u: '//streamable.com/',
  134. q: 'video',
  135. }, {
  136. u: '//gfycat.com/',
  137. q: 'source[src*=".webm"], .actual-gif-image',
  138. }, {
  139. u: '//giphy.com/gifs/',
  140. r: /gifs\/([^/]+-)?(\w+)/,
  141. s: 'https://media.giphy.com/media/$2/giphy.gif',
  142. }, {
  143. u: '//pasteall.org/',
  144. q: '.center-fit',
  145. }, {
  146. u: '//prntscr.com/',
  147. r: /\.com\/(\w+)$/i,
  148. s: 'https://prnt.sc/$1',
  149. q: META_OG_IMG,
  150. xhr: true,
  151. }, {
  152. r: /^(https:\/\/www\.reddit\.com\/)gallery(\/\w+)$/i,
  153. s: '$1comments$2.json',
  154. q: json => Object.values(json[0].data.children[0].data.media_metadata)
  155. .map(v => v.s.u.replace(/&/g, '&')),
  156. }, {
  157. u: 'https://www.sakugabooru.com/post/show/',
  158. q: '#highres',
  159. }, {
  160. u: 'youtu',
  161. r: /\/\/(?:youtu\.be\/|(?:(?:www|m)\.)?youtube\.com\/(?:.*?[&?/]v[=/]|shorts\/))([^&?/#]+)/,
  162. s: 'https://i.ytimg.com/vi/$1/hqdefault.jpg',
  163. }, {
  164. u: '//pbs.twimg.com/media/',
  165. r: /.+?(\?format=|\.\w+:)\w+/,
  166. }, {
  167. u: '.gifv',
  168. r: /(.+?)\.gifv(\?.*)?$/i,
  169. s: '$1.mp4$2',
  170. }];
  171. // last rule: direct images
  172. RULES.push({
  173. r: /\.(jpe?g|png|gif|webm|mp4)(\?.*)?$/i,
  174. });
  175. for (const rule of RULES)
  176. if (rule.u && !Array.isArray(rule.u))
  177. rule.u = [rule.u];
  178.  
  179. // language=CSS
  180. (gm.addStyle || (
  181. css => (document.head || document.documentElement)
  182. .appendChild(Object.assign(document.createElement('style'), {textContent: css}))
  183. ))(`
  184. .${CLASS} {
  185. max-width: 100%;
  186. display: block;
  187. }
  188. .${CLASS}[data-src] {
  189. padding-top: 400px;
  190. }
  191. .${CLASS}:hover {
  192. outline: 2px solid #3bbb62;
  193. }
  194. .${CLASS}.${CLASS_SMALL} {
  195. max-height: 25vh;
  196. }
  197. .${CLASS_ALBUM} {
  198. overflow-y: auto;
  199. max-height: calc(100vh - 100px);
  200. margin: .5em 0;
  201. }
  202. .${CLASS_ALBUM}[${OVERFLOW_ATTR}] {
  203. -webkit-mask-image: linear-gradient(white 75%, transparent);
  204. mask-image: linear-gradient(white 75%, transparent);
  205. }
  206. .${CLASS_ALBUM}[${OVERFLOW_ATTR}]:hover {
  207. -webkit-mask-image: none;
  208. mask-image: none;
  209. }
  210. .${CLASS_ALBUM} > :nth-child(n + 2) {
  211. margin-top: 1em;
  212. }
  213. `);
  214.  
  215. let pageUrl = location.pathname;
  216. let moreTimer;
  217. const observers = new Map();
  218. const more = [];
  219. const toStop = new Set();
  220. const menu = {
  221. get el() {
  222. return $(isOldReddit ? '.drop-choices.inuse' : '[role="menu"]');
  223. },
  224. resolve: null,
  225. observer: new MutationObserver(() => {
  226. const {el} = menu;
  227. if (!el || isOldReddit && !el.classList.contains('inuse')) {
  228. menu.observer.disconnect();
  229. menu.resolve();
  230. }
  231. }),
  232. observerConfig: isOldReddit ?
  233. {attributes: true, attributeFilter: ['class']} :
  234. {childList: true},
  235. };
  236. const scrollObserver = new IntersectionObserver(onScroll, {rootMargin: '150% 0px'});
  237.  
  238. gm.getValue('imgurQuality').then(v => {
  239. Object.assign(cfg, parseConfig(v));
  240. onMutation([{
  241. addedNodes: [document.body],
  242. }]);
  243. new MutationObserver(onMutation)
  244. .observe(document.body, {subtree: true, childList: true});
  245. });
  246.  
  247. //#endregion
  248.  
  249. function onMutation(mutations) {
  250. if (pageUrl !== location.pathname) {
  251. pageUrl = location.pathname;
  252. observers.forEach(o => o.disconnect());
  253. observers.clear();
  254. stopOffscreenImages();
  255. }
  256. const items = [];
  257. let someElementsAdded;
  258. for (const {addedNodes} of mutations) {
  259. for (const node of addedNodes) {
  260. if (!node.localName)
  261. continue;
  262. someElementsAdded = true;
  263. for (const a of node.localName === 'a' ? [node] : node.getElementsByTagName('a')) {
  264. if (a.href.startsWith('https://www.reddit.com/r/') ||
  265. isOldReddit && a.closest('.side, .title, #header'))
  266. continue;
  267. const data = findMatchingRule(a);
  268. if (data)
  269. items.push(data);
  270. }
  271. }
  272. }
  273. if (someElementsAdded && !moreTimer && cfg.expandComments)
  274. moreTimer = setTimeout(observeShowMore, 500);
  275. if (items.length)
  276. setTimeout(maybeExpand, 0, items);
  277. }
  278.  
  279. function onScroll(entries, observer) {
  280. const stoppingScheduled = toStop.size > 0;
  281. const expanders = [];
  282. for (const e of entries) {
  283. let el = e.target;
  284. if (el.localName === 'ins') {
  285. toggleAttribute(el.parentNode, OVERFLOW_ATTR, !e.isIntersecting);
  286. continue;
  287. }
  288. const rect = e.boundingClientRect;
  289. if (!e.isIntersecting) {
  290. if ((rect.bottom < -innerHeight * 2 || rect.top > innerHeight * 2) &&
  291. el.src && !el.dataset.src && observers.has(el))
  292. toStop.add(el);
  293. continue;
  294. } else if (el.classList.contains(CLASS_ALBUM)) {
  295. observer.unobserve(el);
  296. el.appendChild(document.createElement('ins'));
  297. const io = new IntersectionObserver(onScroll, {root: el});
  298. for (const c of el.children)
  299. io.observe(c);
  300. observers.set(el, io);
  301. continue;
  302. }
  303. if (stoppingScheduled)
  304. toStop.delete(el);
  305. const isImage = el.localName === 'img';
  306. if (el.dataset.src && (isImage || el.localName === 'video')) {
  307. el.src = el.dataset.src;
  308. el.addEventListener(isImage ? 'load' : 'loadedmetadata', unobserveOnLoad);
  309. delete el.dataset.src;
  310. continue;
  311. }
  312. if (el.localName === 'a' && el.id) {
  313. // switch to an unfocusable element to prevent the link
  314. // from stealing focus and scrolling the view
  315. const el2 = document.createElement('span');
  316. el2.setAttribute('onclick', el.getAttribute('onclick'));
  317. el2.setAttribute('id', el.id);
  318. el.parentNode.replaceChild(el2, el);
  319. el = el2;
  320. }
  321. expanders[rect.top >= 0 && rect.bottom <= innerHeight ? 'unshift' : 'push'](el);
  322. }
  323. expanders.forEach(expandNextComment);
  324. if (!stoppingScheduled && toStop.size)
  325. setTimeout(stopOffscreenImages, 100);
  326. }
  327.  
  328. function stopOffscreenImages() {
  329. for (const el of toStop) {
  330. if (el.naturalWidth || el.videoWidth)
  331. continue;
  332. el.dataset.src = el.src;
  333. el.removeAttribute('src');
  334. }
  335. toStop.clear();
  336. }
  337.  
  338. function findMatchingRule(a) {
  339. let url = a.href;
  340. for (const rule of RULES) {
  341. if (rule.u && !rule.u.find(includedInThis, url))
  342. continue;
  343. const {r} = rule;
  344. const m = !r || url.match(r);
  345. if (!m)
  346. continue;
  347. if (r && rule.s)
  348. url = url.slice(0, m.index + m[0].length).replace(r, rule.s).slice(m.index);
  349. return {a, rule, url};
  350. }
  351. }
  352.  
  353. function maybeExpand(items) {
  354. for (const item of items) {
  355. const {a, rule} = item;
  356. const {href} = a;
  357. const text = a.textContent.trim();
  358. if (
  359. text &&
  360. !a.getElementsByTagName('img')[0] &&
  361. !/^https?:\/\/\S+?\.{3}$/.test(text) &&
  362. !a.closest(
  363. '.scrollerItem,' +
  364. '[contenteditable="true"],' +
  365. `a[href="${href}"] + * a[href="${href}"],` +
  366. `img[src="${href}"] + * a[href="${href}"]`) &&
  367. (
  368. isOldReddit ||
  369. // don't process insides of a post except for its text
  370. !a.closest('[data-test-id="post-content"]') ||
  371. a.closest('[data-click-id="text"]')
  372. )
  373. ) {
  374. try {
  375. (rule.q ? expandRemote : expand)(item);
  376. } catch (e) {
  377. // console.debug(e, item);
  378. }
  379. }
  380. }
  381. }
  382.  
  383. function expand({a, url = a.href, isAlbum}) {
  384. const isVideo = /(webm|gifv|mp4)(\?.*)?$/i.test(url);
  385. if (!isVideo && url.includes('://i.imgur.com/'))
  386. url = setImgurQuality(url);
  387. let el = isAlbum ? a.lastElementChild : a.nextElementSibling;
  388. if (!el || el.src !== url && el.dataset.src !== url) {
  389. el = document.createElement(isVideo ? 'video' : 'img');
  390. el.dataset.src = url;
  391. el.className = CLASS + (location.pathname.startsWith('/user/') ? ' ' + CLASS_SMALL : '');
  392. a.insertAdjacentElement(isAlbum ? 'beforeEnd' : 'afterEnd', el);
  393. if (isVideo) {
  394. el.controls = true;
  395. el.preload = 'metadata';
  396. }
  397. scrollObserver.observe(el);
  398. }
  399. return !isAlbum && el;
  400. }
  401.  
  402. async function expandRemote(item) {
  403. const {url, rule} = item;
  404. const r = await download(url);
  405. const text = r.response;
  406. const isJSON = /^content-type:.*?\/json/mi.test(r.responseHeaders);
  407. const doc = isJSON ? tryJSONparse(text) : parseDoc(text, url);
  408. switch (typeof rule.q) {
  409. case 'string': {
  410. if (!isJSON)
  411. expandRemoteFromSelector(doc, item);
  412. return;
  413. }
  414. case 'function': {
  415. let urls;
  416. try {
  417. urls = await rule.q(doc, text, item);
  418. } catch (e) {}
  419. if (urls && urls.length) {
  420. urls = Array.isArray(urls) ? urls : [urls];
  421. expandFromUrls(urls, item);
  422. }
  423. return;
  424. }
  425. }
  426. }
  427.  
  428. async function expandRemoteFromSelector(doc, {rule, url, a}) {
  429. if (!doc)
  430. return;
  431. const el = doc.querySelector(rule.q);
  432. if (!el)
  433. return;
  434. let imageUrl = el.href || el.src || el.content;
  435. if (!imageUrl)
  436. return;
  437. if (rule.xhr)
  438. imageUrl = await downloadAsBase64({imageUrl, url});
  439. if (imageUrl)
  440. expand({a, url: imageUrl});
  441. }
  442.  
  443. function expandFromUrls(urls, {a}) {
  444. const isAlbum = urls.length > 1;
  445. if (isAlbum) {
  446. if (a.nextElementSibling && a.nextElementSibling.classList.contains(CLASS_ALBUM))
  447. return;
  448. a = a.insertAdjacentElement('afterEnd', document.createElement('div'));
  449. a.className = CLASS_ALBUM;
  450. scrollObserver.observe(a);
  451. }
  452. for (const url of urls) {
  453. if (url)
  454. a = expand({a, url, isAlbum}) || a;
  455. }
  456. }
  457.  
  458. async function expandNextComment(el) {
  459. if (el)
  460. more.push(el);
  461. else
  462. more.shift();
  463. if (more.length === 1 || !el && more.length) {
  464. if (menu.el) {
  465. await new Promise(resolve => {
  466. menu.resolve = resolve;
  467. menu.observer.observe(isOldReddit ? menu.el : document.body, menu.observerConfig);
  468. });
  469. }
  470. const a = more[0];
  471. if (a.href) {
  472. expandDeepThread(a);
  473. } else {
  474. a.dispatchEvent(new MouseEvent('click', {bubbles: true}));
  475. }
  476. a.removeAttribute('onclick');
  477. more.forEach((el, i) => {
  478. scrollObserver.unobserve(el);
  479. if (i) scrollObserver.observe(el);
  480. });
  481. more.length = 1;
  482. setTimeout(expandNextComment, REQUEST_THROTTLE_MS);
  483. }
  484. }
  485.  
  486. async function expandDeepThread(a) {
  487. try {
  488. a.style.opacity = .25;
  489. const url = a.href;
  490. const doc = parseDoc(await (await fetch(url)).text(), url);
  491. const table = $('.sitetable.nestedlisting', doc);
  492. const thing = $('.thing', table);
  493. const oldThing = document.getElementById(thing.id);
  494. if (oldThing) {
  495. oldThing.replaceWith(thing);
  496. } else {
  497. table.classList.remove('nestedlisting');
  498. a.closest('.thing').replaceWith(table);
  499. }
  500. } catch (e) {}
  501. }
  502.  
  503. function observeShowMore() {
  504. moreTimer = 0;
  505. if ($(MORE_SELECTOR)) {
  506. for (const el of $$(MORE_SELECTOR)) {
  507. scrollObserver.observe(el);
  508. }
  509. }
  510. }
  511.  
  512. function tryJSONparse(str) {
  513. try {
  514. return JSON.parse(str);
  515. } catch (e) {}
  516. }
  517.  
  518. function download(options) {
  519. return new Promise((resolve, reject) => {
  520. gm.xmlHttpRequest(Object.assign({
  521. method: 'GET',
  522. onload: resolve,
  523. onerror: reject,
  524. }, typeof options === 'string' ? {url: options} : options));
  525. });
  526. }
  527.  
  528. async function downloadAsBase64({imageUrl, url}) {
  529. let {response: blob} = await download({
  530. url: imageUrl,
  531. headers: {
  532. Referer: url,
  533. },
  534. responseType: 'blob',
  535. });
  536.  
  537. if (blob.type !== getMimeType(imageUrl))
  538. blob = blob.slice(0, blob.size, getMimeType(imageUrl));
  539.  
  540. return new Promise(resolve => {
  541. Object.assign(new FileReader(), {
  542. onload: e => resolve(e.target.result),
  543. }).readAsDataURL(blob);
  544. });
  545. }
  546.  
  547. function parseDoc(text, url) {
  548. const doc = new DOMParser().parseFromString(text, 'text/html');
  549. if (!doc.querySelector('base'))
  550. doc.head.appendChild(doc.createElement('base')).href = url;
  551. return doc;
  552. }
  553.  
  554. function getMimeType(url) {
  555. const ext = (url.match(/\.(\w+)(\?.*)?$|$/)[1] || '').toLowerCase();
  556. return 'image/' + (ext === 'jpg' ? 'jpeg' : ext);
  557. }
  558.  
  559. function toggleAttribute(el, name, state) {
  560. const oldState = el.hasAttribute(name);
  561. if (state && !oldState)
  562. el.setAttribute(name, '');
  563. else if (!state && oldState)
  564. el.removeAttribute(name);
  565. }
  566.  
  567. function unobserveOnLoad() {
  568. this.removeEventListener(this.localName === 'img' ? 'load' : 'loadedmetadata', unobserveOnLoad);
  569. const io = observers.get(this);
  570. if (io) io.unobserve(this);
  571. }
  572.  
  573. function includedInThis(needle) {
  574. const i = this.indexOf(needle);
  575. // URL should have something after `u` part if that ends with '/'
  576. return i >= 0 && (!this.endsWith('/') || this.length > i + needle.length);
  577. }
  578.  
  579. function setImgurQuality(url) {
  580. const i = url.lastIndexOf('/') + 1;
  581. const j = url.lastIndexOf('.');
  582. const ext = url.slice(j);
  583. return url.slice(0, Math.min(i + 7, j)) + (i && j > i && !/webm|mp4/.test(ext) ? cfg.imgurQuality : '') + '.jpg';
  584. }
  585.  
  586. function configure() {
  587. const str = stringifyConfig(cfg);
  588. const q = prompt(
  589. 'Toggle comment expansion and set imgur quality like +h or -b where ' +
  590. '+ or - toggles comment expansion and h, l, m, t, b, s sets imgur quality: ' +
  591. 'Huge, Large, Medium, Thumbnail, Big square, Small square, or no letter to use default.',
  592. str);
  593. if (q == null) return;
  594. const cfg2 = parseConfig(q);
  595. const str2 = stringifyConfig(cfg2);
  596. if (str2 === str) return;
  597. gm.setValue('imgurQuality', str2);
  598. if (cfg.imgurQuality !== cfg2.imgurQuality) {
  599. const selector = `.${CLASS}!, .${CLASS_SMALL}!, .${CLASS}@, .${CLASS_SMALL}@`
  600. .replace(/!/g, '[src*="imgur.com"]').replace(/@/g, '[data-src*="imgur.com"]');
  601. for (const el of $$(selector)) {
  602. const src = el.src || el.dataset.src;
  603. const newSrc = setImgurQuality(src);
  604. if (src !== newSrc)
  605. el.setAttribute(el.src ? 'src' : 'data-src', newSrc);
  606. }
  607. }
  608. if (cfg.expandComments !== cfg2.expandComments) {
  609. if (cfg2.expandComments) {
  610. observeShowMore();
  611. } else {
  612. clearTimeout(moreTimer);
  613. more.length = moreTimer = 0;
  614. }
  615. }
  616. Object.assign(cfg, cfg2);
  617. }