Twitter/X - clickable links to images and show uncropped thumbnails

All image posts in Twitter/X Home, other blog streams and single post views link to the high-res "orig" version. Thumbnail images in the stream are modified to display uncropped.

  1. // ==UserScript==
  2. // @name Twitter/X - clickable links to images and show uncropped thumbnails
  3. // @namespace twitter_linkify
  4. // @version 4.3
  5. // @license GNU AGPLv3
  6. // @description All image posts in Twitter/X Home, other blog streams and single post views link to the high-res "orig" version. Thumbnail images in the stream are modified to display uncropped.
  7. // @author marp
  8. // @homepageURL https://greasyfork.org/en/users/204542-marp
  9. // @match https://twitter.com/
  10. // @match https://twitter.com/*
  11. // @match https://pbs.twimg.com/media/*
  12. // @exclude https://twitter.com/settings
  13. // @exclude https://twitter.com/settings/*
  14. // @match https://x.com/
  15. // @match https://x.com/*
  16. // @exclude https://x.com/settings
  17. // @exclude https://x.com/settings/*
  18. // @grant GM_xmlhttpRequest
  19. // @connect pbs.twimg.com
  20. // @run-at document-end
  21. // ==/UserScript==
  22.  
  23. // jshint esversion:8
  24.  
  25.  
  26. function adjustSingleMargin(myNode) {
  27. // I SHOULD remove only margin-... values - but there never seems to be anything else - so go easy way and remove ALL style values
  28. var myStyle = myNode.getAttribute("style");
  29. if ( (myStyle !== null) && ( myStyle.includes("margin") || !(myStyle.includes("absolute")) ) ) {
  30. myNode.setAttribute("style", "position: absolute; top: 0px; bottom: 0px; left: 0px; right: 0px");
  31. }
  32. }
  33.  
  34. function adjustSingleBackgroundSize(myNode) {
  35. var myStyle = myNode.getAttribute("style");
  36. if ( (myStyle !== null) && ( !(myStyle.includes("contain")) ) ) {
  37. myNode.style.backgroundSize = "contain";
  38. }
  39. }
  40.  
  41.  
  42. function createSingleImageLink(myDoc, myContext) {
  43.  
  44. if (myContext.nodeType === Node.ELEMENT_NODE) {
  45.  
  46. var singlematch;
  47. var singlelink;
  48. var observer;
  49. var config;
  50. singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/') and ancestor::article]",
  51. myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  52. singlelink = singlematch.singleNodeValue;
  53. if (singlelink !== null) {
  54.  
  55. // persistently remove "margin-..." styles (they "de-center" the images)
  56. singlematch=myDoc.evaluate(".//div[@aria-label='Image' or @data-testid='tweetPhoto']",
  57. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  58. var singlenode = singlematch.singleNodeValue;
  59. if (singlenode !== null) {
  60. adjustSingleMargin(singlenode);
  61. observer = new MutationObserver(function(mutations) {
  62. mutations.forEach(function(mutation) {
  63. adjustSingleMargin(mutation.target);
  64. });
  65. });
  66. config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
  67. observer.observe(singlenode, config);
  68. }
  69.  
  70. // persistently change image zoom from "cover" to "contain" - this ensures that the full thumbnail is visible
  71. singlematch=myDoc.evaluate(".//div[contains(@style,'background-image')]",
  72. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  73. singlenode = singlematch.singleNodeValue;
  74. if (singlenode !== null) {
  75. adjustSingleBackgroundSize(singlenode)
  76. observer = new MutationObserver(function(mutations) {
  77. mutations.forEach(function(mutation) {
  78. adjustSingleBackgroundSize(mutation.target);
  79. });
  80. });
  81. config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
  82. observer.observe(singlenode, config);
  83. }
  84.  
  85. // change the link to point to the "orig" version of the image directly
  86. singlematch=myDoc.evaluate(".//img[contains(@src,'https://pbs.twimg.com/media/') and contains(@src,'name=')]",
  87. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  88. var imagenode = singlematch.singleNodeValue;
  89. if (imagenode !== null) {
  90. var imgurl = new URL(imagenode.getAttribute("src"));
  91. var params = new URLSearchParams(imgurl.search.substring(1));
  92. if (!(params.has("format", "webp"))) {
  93. // WebP links require special treatment as the original image file might be of different format (Twitter/X network traffic optimization, it seems).
  94. // WebP image url are left unchanged and are then "post-processed" by another part of this script, which triggers on the image file itself (see code towards end of script)
  95. // The idea is to open a working webp image link which the scedonf scfipt part can then post-process.
  96. // A "name=orig" url can lead to a 404 error, which cannot be post-processed in Chromium browsers.
  97. params.set("name", "orig");
  98. }
  99. imgurl.search = "?" + params.toString();
  100. singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
  101. imagenode, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  102. singlenode = singlematch.singleNodeValue;
  103. if (singlenode !== null) {
  104. singlenode.href = imgurl.href;
  105. }
  106. }
  107.  
  108. }
  109. }
  110. }
  111.  
  112.  
  113. function processImages(myDoc, myContext) {
  114.  
  115. //console.info("processImages-0 ", myContext);
  116.  
  117. if (myContext.nodeType === Node.ELEMENT_NODE) {
  118.  
  119. var singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
  120. myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  121. var singlenode=singlematch.singleNodeValue;
  122. if (singlenode !== null) {
  123.  
  124. createSingleImageLink(myDoc, singlenode); // applies if the added node is descendant or equal to a single image link
  125.  
  126. } else {
  127.  
  128. // this assumes that the added node CONTAINS image link(s), i.e. is an ancestor of image(s)
  129. var matches=myDoc.evaluate("./descendant-or-self::a[contains(@href,'/photo/')]",
  130. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  131. for(var i=0, el; (i<matches.snapshotLength); i++) {
  132. el=matches.snapshotItem(i);
  133. createSingleImageLink(myDoc, el);
  134. }
  135.  
  136. }
  137. }
  138. }
  139.  
  140.  
  141. var blurStyles = null; // some styles are added on-demand, but once we get the style for the image blurring, we stop updating the list and use this cache for performance reasons
  142. var blurStylesStop = false;
  143. function processBlurring(myDoc, myContext) {
  144.  
  145. if (myContext.nodeType === Node.ELEMENT_NODE) {
  146.  
  147. if (!blurStylesStop) {
  148. // Find all CSS that implement blurring - example match: ".r-yfv4eo { filter: blur(30px); }"
  149. // Keep the style names of these matches in an array
  150. // NOTE: This code assumes that all these CSS have selectors without element types, i.e. ".r-yfv4eo" instead of "div.r-yfv4eo"
  151. blurStyles = Array.from(myDoc.styleSheets).filter(ss => { try { return ss.cssRules.length > 0; } catch (e) { return false; } } ).flatMap(ss => Array.from(ss.cssRules).filter(css => css instanceof CSSStyleRule && css.cssText.indexOf('blur(')>=0)).map(css => css.selectorText.substring(1));
  152. }
  153.  
  154. var matches;
  155. var pos;
  156. for (const bs of blurStyles) {
  157. matches = myDoc.evaluate("./descendant-or-self::div[contains(@class, '"+bs+"')]", myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  158. for(var i=0, el; (i<matches.snapshotLength); i++) {
  159. el=matches.snapshotItem(i);
  160. el.className = el.className.replace(bs, ''); //remove the blurring
  161. // remove the overlay with the info text and button to show/ide (assumption: it is always the next sibling element)
  162. if (el.nextSibling !== null) {
  163. el.nextSibling.remove();
  164. blurStylesStop = true; // found and used the correct blurring style - stop searching and rebuilding the style list (performance)
  165. }
  166. }
  167.  
  168. }
  169. }
  170. }
  171.  
  172.  
  173.  
  174. function observeArticles(myDoc, myContext) {
  175.  
  176. if (myContext.nodeType === Node.ELEMENT_NODE) {
  177.  
  178. var singlematch;
  179. var matches;
  180. matches=myDoc.evaluate("./descendant-or-self::article[./ancestor::section/ancestor::div[@data-testid='primaryColumn']/ancestor::main]",
  181. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  182. for(var i=0, el; (i<matches.snapshotLength); i++) {
  183. el=matches.snapshotItem(i);
  184.  
  185. processImages(myDoc, el);
  186. processBlurring(myDoc, el);
  187.  
  188. var observer = new MutationObserver(function(mutations) {
  189. mutations.forEach(function(mutation) {
  190. mutation.addedNodes.forEach(function(addedNode) {
  191. processImages(mutation.target.ownerDocument, addedNode);
  192. processBlurring(mutation.target.ownerDocument, addedNode);
  193. });
  194. });
  195. });
  196. var config = { attributes: false, childList: true, characterData: false, subtree: true };
  197. observer.observe(el, config);
  198. }
  199. }
  200. }
  201.  
  202.  
  203. function insertLinkElement(myDoc, wrapElement, linkTarget, downloadName) {
  204. var newnode;
  205. var parentnode;
  206.  
  207. newnode = myDoc.createElement("a");
  208. newnode.setAttribute("href", linkTarget);
  209. newnode.setAttribute("target", "_blank");
  210. newnode.setAttribute("download", downloadName);
  211. parentnode = wrapElement.parentNode;
  212. parentnode.replaceChild(newnode, wrapElement);
  213. newnode.appendChild(wrapElement);
  214. }
  215.  
  216.  
  217. function getCleanImageURL(imageurl) {
  218. var pos = imageurl.toLowerCase().lastIndexOf(":");
  219. var pos2 = imageurl.toLowerCase().indexOf("/");
  220. if (pos >= 0 && pos > pos2) {
  221. return imageurl.substring(0, pos);
  222. } else {
  223. return imageurl;
  224. }
  225. }
  226.  
  227.  
  228. function getFilename(imageurl) {
  229. return getCleanImageURL(imageurl).substring(imageurl.toLowerCase().lastIndexOf("/")+1);
  230. }
  231.  
  232.  
  233. // This ASYNC method returns a promise to retrieve the HTTP response header data for the supplied URL.
  234. // It uses an "HTTP HEAD" request which does NOT download the response payload (to minimize network traffic)
  235. async function checkUrlHeaderOnlyPromise(url) {
  236. return new Promise((resolve, reject) => {
  237. GM_xmlhttpRequest({
  238. method: 'HEAD',
  239. url: url,
  240. onload: function(response) {
  241. if ((response.readyState >= 2) && (response.status == 200)) {
  242. resolve( { url: response.finalUrl, origurl: url} );
  243. } else {
  244. reject( { url: url, origurl: url } );
  245. }
  246. },
  247. ontimeout: function(response) {
  248. reject( { url: url, origurl: url } );
  249. },
  250. onerror: function(response) {
  251. reject( { url: url, origurl: url } );
  252. }
  253. });
  254. });
  255. }
  256.  
  257.  
  258.  
  259. // Helper function for part ofd script that executed on direct image links
  260. function findOrigUrl(mycheckurl, ischeckwebp, mydocument) {
  261. var checkURL = new URL(mycheckurl);
  262. var checkPromises1 = new Array( (ischeckwebp ? 3 : 2) );
  263.  
  264. checkURL.searchParams.set("format", "jpg");
  265. checkPromises1[0] = checkUrlHeaderOnlyPromise(checkURL.href);
  266. checkURL.searchParams.set("format", "png");
  267. checkPromises1[1] = checkUrlHeaderOnlyPromise(checkURL.href);
  268. if (ischeckwebp) {
  269. checkURL.searchParams.set("format", "webp");
  270. checkPromises1[2] = checkUrlHeaderOnlyPromise(checkURL.href);
  271. }
  272. // wait until at least one URL has successfully resolved (i.e. HTTP headers loaded without error)
  273. Promise.any(checkPromises1).then(
  274. // SUCCESS -> DONE, navigate to the working url
  275. (result1) => { mydocument.location.href = result1.url; },
  276. // FAILURE -> try the remaining, more exotic image formats (list of all formats determined by file types supported in "Open File" dialog when uploading an image to Twitter/X
  277. () => {
  278. var checkPromises2 = new Array(4);
  279. checkURL.searchParams.set("format", "jpeg");
  280. checkPromises2[0] = checkUrlHeaderOnlyPromise(checkURL.href);
  281. checkURL.searchParams.set("format", "jfif");
  282. checkPromises2[1] = checkUrlHeaderOnlyPromise(checkURL.href);
  283. checkURL.searchParams.set("format", "pjpeg");
  284. checkPromises2[2] = checkUrlHeaderOnlyPromise(checkURL.href);
  285. checkURL.searchParams.set("format", "pjp");
  286. checkPromises2[3] = checkUrlHeaderOnlyPromise(checkURL.href);
  287. // wait until at least one URL has successfully resolved (i.e. HTTP headers loaded without error)
  288. Promise.any(checkPromises2).then(
  289. // SUCCESS -> DONE, navigate to the working url
  290. (result2) => { mydocument.location.href = result2.url; },
  291. // FAILURE -> found no working alternative image url -> do nothing, stay on current url.
  292. () => { /* do nothing */ }
  293. );
  294. }
  295. );
  296. }
  297.  
  298.  
  299. // TWO very different actions depending on if this is on twitter.com/x.com or twimg.com
  300. // == 1: twing.com -> deal with direct image URLs
  301. if (window.location.href.includes('pbs.twimg.com/media')){
  302.  
  303. var params = new URLSearchParams(document.location.search.substring(1));
  304.  
  305. if (params.has("name")) {
  306.  
  307. if ( !(params.has("name", "orig")) ) {
  308.  
  309. if (params.has("format", "webp")) {
  310. // IMAGE URL being loaded is WebP -> orig image might be of different format -> test various formats and navigate if match found (asynch)
  311. var checkURL = new URL(document.location.href);
  312. checkURL.searchParams.set("name", "orig");
  313. findOrigUrl(checkURL.href, true, document);
  314. } else {
  315. // IMAGE URL being loaded is not WebP -> modify image URL to go to "orig" destination
  316. params.set("name", "orig");
  317. document.location.search = "?" + params.toString();
  318. }
  319. }
  320. }
  321.  
  322. }
  323. else
  324. {
  325.  
  326. // == 2: twitter.com/x.com -> modify Twitter/X pages
  327. var reactrootmatch = document.evaluate("//div[@id='react-root']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  328. var reactrootnode = reactrootmatch.singleNodeValue;
  329.  
  330. if (reactrootnode !== null) {
  331. // create an observer instance and iterate through each individual new node
  332. var observer = new MutationObserver(function(mutations) {
  333. mutations.forEach(function(mutation) {
  334. mutation.addedNodes.forEach(function(addedNode) {
  335. observeArticles(mutation.target.ownerDocument, addedNode);
  336. });
  337. });
  338. });
  339.  
  340. // configuration of the observer
  341. var config = { attributes: false, childList: true, characterData: false, subtree: true };
  342.  
  343. //process already loaded nodes (the initial posts before scrolling down for the first time)
  344. observeArticles(document, reactrootnode);
  345.  
  346. //start the observer for new nodes
  347. observer.observe(reactrootnode, config);
  348. }
  349. }