TweetDeck Image Assistant

Download/Share Images Faster

  1. // ==UserScript==
  2. // @name TweetDeck Image Assistant
  3. // @namespace http://ejew.in/
  4. // @version 1.1
  5. // @description Download/Share Images Faster
  6. // @author EntranceJew
  7. // @match https://tweetdeck.twitter.com*
  8. // @require https://cdn.rawgit.com/eligrey/FileSaver.js/5ed507ef8aa53d8ecfea96d96bc7214cd2476fd2/FileSaver.min.js
  9. // @require https://cdn.rawgit.com/kamranahmedse/jquery-toast-plugin/1105577ed71ef368f8aa3d96295857643dca43d7/dist/jquery.toast.min.js
  10. // @noframes
  11. // @resource toastCSS https://cdn.rawgit.com/kamranahmedse/jquery-toast-plugin/1105577ed71ef368f8aa3d96295857643dca43d7/dist/jquery.toast.min.css
  12. // @grant GM_addStyle
  13. // @grant GM_getResourceText
  14. // ==/UserScript==
  15. /*
  16. 1.1 - performance fixes, ctrl+hotlink doesn't destroy the first image's media link anymore, better button insertion, auto-follow lockfind updated, buttons don't appear in DMs anymore
  17. 1.0 - fixed issue with logic in setting default image resolution
  18. 0.9 - toast notifications, better clipboard access methods, better image sources, ctrl+click like/rt/download to follow from column owner, fixed errors in previewer
  19. 0.8 - added t.co link unmasking
  20. 0.7 - apparently getting gif sources works most reliably inside callbacks
  21. 0.6 - hotfix to prevent redundant page reloading with stream-media seek methods
  22. 0.5 - video links no longer destroy links, ctrl+click the timestamp to copy the tweet link, ctrl+click the link icon to prepare multi-image tweets for discord
  23. 0.4 - changed download icon, added copy links button, videos now don't flash their preview, videos no longer close your draft tweets panel
  24. 0.3 - gif support wasn't that hard
  25. 0.2 - removed debug prints, updated mimes, added video download link, instant-spice now grabs videos
  26. 0.1 - initial version
  27. */
  28.  
  29. /* # todo:
  30. * move code into bettertweetdeck
  31. * make it so that toasts batch by purpose, extending notification times
  32. * better support for tweet-details view (sometimes causing lockouts?)
  33. * better support for modal big preview tweets
  34. * stack quote-tweets
  35. * do not count quoted media as a media source
  36. * context menu item insertion
  37. * context sensitive actions
  38. * custom action icons / styles
  39. * update action button tooltips when modifier keys are pressed
  40. * action to follow all explicitly mentioned users in a tweet (not in the conversation)
  41. * better tweetdeck integration
  42. */
  43.  
  44. (function() {
  45. 'use strict';
  46.  
  47. GM_addStyle( GM_getResourceText("toastCSS") );
  48.  
  49. var settings = {
  50. toast: {
  51. hideAfter: 1000,
  52. }
  53. };
  54.  
  55. var toast_prototype = {
  56. text: "Don't forget to star the repository if you like it.", // Text that is to be shown in the toast
  57. heading: 'Note', // Optional heading to be shown on the toast
  58. icon: 'success', // Type of toast icon
  59. showHideTransition: 'slide', // fade, slide or plain
  60. allowToastClose: true, // Boolean value true or false
  61. hideAfter: settings.toast.hideAfter, // false to make it sticky or number representing the miliseconds as time after which toast needs to be hidden
  62. stack: 32, // false if there should be only one toast at a time or a number representing the maximum number of toasts to be shown at a time
  63. position: 'bottom-left', // bottom-left or bottom-right or bottom-center or top-left or top-right or top-center or mid-center or an object representing the left, right, top, bottom values
  64.  
  65. textAlign: 'left', // Text alignment i.e. left, right or center
  66. loader: true, // Whether to show loader or not. True by default
  67. loaderBg: '#9EC600', // Background color of the toast loader
  68. beforeShow: function () {}, // will be triggered before the toast is shown
  69. afterShown: function () {}, // will be triggered after the toat has been shown
  70. beforeHide: function () {}, // will be triggered before the toast gets hidden
  71. afterHidden: function () {} // will be triggered after the toast has been hidden
  72. };
  73.  
  74. function toast( heading, text, icon ){
  75. return $.toast(jQuery.extend(true, toast_prototype, {
  76. heading: heading,
  77. text: text,
  78. icon: icon
  79. }));
  80. }
  81.  
  82. var toolbar_size = 6;
  83. var tool_icon_width = (1 / toolbar_size) * 100;
  84.  
  85. GM_addStyle( `
  86. .tweet-detail-action-item,
  87. .without-tweet-drag-handles .tweet-detail-action-item {
  88. width: ` + tool_icon_width + `% !important;
  89. }
  90.  
  91. .jq-toast-single {
  92. word-break: break-all;
  93. }
  94. `);
  95.  
  96. function add_action_style( name, details ){
  97. var style = `
  98. .tweet-action:hover .icon-` + details.icon_name +`,
  99. .tweet-detail-action:hover .icon-` + details.icon_name +`,
  100. .dm-action:hover .icon-` + details.icon_name +`,
  101. .tweet-action:focus .icon-` + details.icon_name +`,
  102. .tweet-detail-action:focus .icon-` + details.icon_name +`,
  103. .dm-action:focus .icon-` + details.icon_name +`,
  104. .tweet-action:active .icon-` + details.icon_name +`,
  105. .tweet-detail-action:active .icon-` + details.icon_name +`,
  106. .dm-action:active .icon-` + details.icon_name +`,
  107. .tweet-action.is-selected .icon-` + details.icon_name +`,
  108. .is-selected.tweet-detail-action .icon-` + details.icon_name +`,
  109. .is-selected.dm-action .icon-` + details.icon_name +` {
  110. color: ` + details.color + `;
  111. }`;
  112. GM_addStyle( style );
  113. }
  114.  
  115. //.icon-link:before{ content: "\f088"; }
  116. var action_properties = {
  117. 'download': {
  118. 'name': 'Download',
  119. 'tool_tip': 'Download',
  120. 'rel': 'download',
  121. 'icon_name': 'attachment',
  122. 'before_content': "\f088",
  123. 'color': '#D400E0',
  124. 'on': {
  125. 'click': [
  126. ['download_url', ['media_link']],
  127. 'media_next',
  128. ['download_url', ['media_link']],
  129. 'media_next',
  130. ['download_url', ['media_link']],
  131. 'media_next',
  132. ['download_url', ['media_link']],
  133. 'media_next',
  134. ],
  135. 'ctrl+click': [
  136. 'default',
  137. 'follow'
  138. ]
  139. },
  140. },
  141. 'hotlink': {
  142. 'name': 'Hotlink',
  143. 'tool_tip': 'Hotlink',
  144. 'rel': 'hotlink',
  145. 'icon_name': 'link',
  146. 'before_content': "\f098",
  147. 'color': '#00498A',
  148. 'on': {
  149. 'click': [
  150. 'clipboard_open',
  151. ['clipboard_push', ['media_link']],
  152. 'media_next',
  153. ['clipboard_push', ['media_link']],
  154. 'media_next',
  155. ['clipboard_push', ['media_link']],
  156. 'media_next',
  157. ['clipboard_push', ['media_link']],
  158. 'media_next',
  159. 'clipboard_copy'
  160. ],
  161. 'ctrl+click': [
  162. 'clipboard_open',
  163. ['clipboard_push', ['tweet_link']],
  164. 'media_next',
  165. 'media_next',
  166. ['clipboard_push', ['media_link']],
  167. 'media_next',
  168. ['clipboard_push', ['media_link']],
  169. 'media_next',
  170. ['clipboard_push', ['media_link']],
  171. 'media_next',
  172. 'clipboard_copy'
  173. ],
  174. },
  175. },
  176. };
  177.  
  178. for (var name in action_properties) {
  179. add_action_style(name, action_properties[name]);
  180. }
  181.  
  182. var possible_locations = {
  183. 'single': {
  184. 'context': '.tweet-detail-wrapper > article.stream-item',
  185. //'toolbar': 'tweet-detail-actions',
  186. //'item': 'tweet-detail-action-item',
  187. 'filters': [
  188. ":not('.feature-customtimelines')",
  189. ":last"
  190. ],
  191. 'images': [
  192. 'img.media-img',
  193. ],
  194. },
  195. 'normal': {
  196. 'context': 'article.stream-item',
  197. //'toolbar': 'tweet-actions',
  198. //'item': 'tweet-action-item',
  199. 'filters': [
  200. ":not('.feature-customtimelines')",
  201. ":last"
  202. ]
  203. },
  204. 'modal': {
  205. 'context': 'div#open-modal div.item-box',
  206. //'toolbar':
  207. //
  208. 'filters': []
  209. }
  210. };
  211.  
  212. function add_to_toolbar( context = 'html', action = action_properties.download, location = possible_locations.normal ){
  213. // var slugged = action.name.replace(/\s/g, '').toLowerCase();
  214.  
  215. // clone reply
  216. var x = $( context ).find( "ul[class*=-actions] > li:first" ).clone();
  217.  
  218. // set the attributes
  219. x.find('a')
  220. .removeClass('js-reply-action')
  221. .addClass('js-'+action.rel+'-action')
  222. .addClass('js-show-tip')
  223. .attr('rel', action.rel )
  224. .attr('title', action.tool_tip )
  225. .data('original-title', action.tool_tip );
  226.  
  227. x.find('i')
  228. .removeClass('icon-reply')
  229. .addClass('icon-'+action.icon_name)
  230. .addClass('icon-'+action.icon_name+'-toggle');
  231.  
  232. x.find('span.is-vishidden')
  233. .text( action.tool_tip );
  234.  
  235. // plant it back at the end
  236. var z = $( context ).find( "ul[class*=-actions] > li" );
  237. location.filters.forEach(function(element){
  238. z = $( z ).filter( element );
  239. });
  240. z = $( z ).prev();
  241. $( x ).insertAfter( $(z) );
  242.  
  243. return $( x );
  244. }
  245.  
  246. var mime_db = {
  247. jpeg: "image/jpeg",
  248. jpg: "image/jpeg",
  249. gif: "image/gif",
  250. webp: "image/webp",
  251. mp4: "video/mp4",
  252. m3u8: "application/x-mpegURL",
  253. undefined: "text/plain"
  254. };
  255.  
  256. function clipboard_data( text ){
  257. var tc = $('.compose-text-container .js-compose-text');
  258. var orig = tc.val();
  259. var active = document.activeElement;
  260. tc.val( text );
  261. tc[0].focus();
  262. tc[0].setSelectionRange( 0, text.length );
  263. document.execCommand("copy");
  264. tc.val( orig );
  265. active.focus();
  266. toast("Copied <em>" + text.split(/\r*\n/).length + "</em> Lines!", text.replace(/\r*\n/, "<br>"), "info");
  267. }
  268.  
  269. // http://stackoverflow.com/a/2091331
  270. function getQueryVariable(str, variable) {
  271. var query = str.substring(1);
  272. var vars = query.split('&');
  273. for (var i = 0; i < vars.length; i++) {
  274. var pair = vars[i].split('=');
  275. if (decodeURIComponent(pair[0]) == variable) {
  276. return decodeURIComponent(pair[1]);
  277. }
  278. }
  279. console.log('Query variable %s not found', variable);
  280. }
  281.  
  282. function detect_mime(url){
  283. return mime_db[ /(?:\.([^.]+))?$/.exec(url)[1] ];
  284. }
  285.  
  286. function get_img_data( url, on_load ) {
  287. var xhr = new XMLHttpRequest();
  288. xhr.open("GET", url);
  289. xhr.responseType = "blob";
  290. xhr.onload = on_load;
  291. xhr.send();
  292. }
  293.  
  294. function is_empty( str ){
  295. return !(str && typeof(str) == "string" && str.trim().length !== 0 && str !== "none");
  296. }
  297.  
  298. function unique(array) {
  299. return $.grep(array, function(el, index) {
  300. return index === $.inArray(el, array);
  301. });
  302. }
  303.  
  304. function unpack_sources( packed_sources, context ){
  305. var unpacked = [];
  306. for( var str in packed_sources ){
  307. var src = packed_sources[str];
  308. if( typeof(src) === "function" ){
  309. src = src( context );
  310. }
  311. if( !is_empty( src ) ){
  312. unpacked.push( nice_url( src ) );
  313. }
  314. }
  315. return unique(unpacked);
  316. }
  317.  
  318. function download_now( url, prefix = "twitter_" ){
  319. if( url.length ){
  320. get_img_data( url, function( e ){
  321. var img_name = url.substring( url.lastIndexOf('/')+1 );
  322. var the_blob = new Blob([this.response], {type: detect_mime(url)});
  323. var save_file_name = img_name.replace(/:orig$/, "");
  324. saveAs( the_blob, prefix + save_file_name );
  325. if( save_file_name.endsWith('mp4') ){
  326. toast("Downloaded <em>1</em> Video!", save_file_name, "info");
  327. }
  328. });
  329. }
  330. }
  331.  
  332. function nice_url( url, replacement ){
  333. if( replacement === "" ){
  334. // whatever
  335. } else if( !replacement ){
  336. replacement = ":orig";
  337. }
  338.  
  339. var bg = url;
  340. bg = bg.replace('url(','').replace(')','').replace(/\"/gi, "");
  341. bg = bg.replace(/:thumb$/, replacement);
  342. bg = bg.replace(/:small$/, replacement);
  343. bg = bg.replace(/:medium$/, replacement);
  344. bg = bg.replace(/:large$/, replacement);
  345. return bg;
  346. }
  347.  
  348. // danger: this could potentially lockup if the element isn't guaranteed to appear.
  349. function lock_find( selector, context ){
  350. var results = $( selector, context );
  351. while( !results.length ){
  352. results = $( selector, context );
  353. }
  354. return results;
  355. }
  356.  
  357. // we have to do literal jungle japes in order to get to the follow button from here
  358. // strap in
  359. function follow_tweet( selector ){
  360. $( selector ).find( "ul[class*=-actions] > li:not(.feature-customtimelines) i.icon-more" ).filter(":last").click();
  361. var column_owner = $( selector ).parents('.column-panel').find('h1.column-title span.attribution').text();
  362. var more = lock_find('.js-dropdown.dropdown-menu a[data-action="followOrUnfollow"]', $( selector ));
  363. $( more ).parent('li.is-selectable').addClass('is-selected');
  364. $( more ).click();
  365. var follow_container = lock_find('div.js-modal-context');
  366. var column_owner_follow = null;
  367.  
  368. // entrancejew only follows from his third account
  369. // entrancejew also refuses to implement settings yet
  370. if( column_owner == "@EntranceJew" ){
  371. column_owner_follow = lock_find('div.js-follow-from:nth-child(3)', follow_container);
  372. } else {
  373. follow_container.find('.js-from-username').each(function(){
  374. var this_name = $( this ).text();
  375. if( this_name.includes( column_owner ) ){
  376. column_owner_follow = $( this ).parent('.js-follow-from');
  377. }
  378. });
  379. }
  380.  
  381. var follow_button = null;
  382. var follow_seeker = setInterval(function(){
  383. follow_button = column_owner_follow.find('.js-action-follow[class*=" s-"]');
  384. if( follow_button.length ){
  385. if( follow_button.hasClass('s-not-following') ){
  386. var user_to_follow = $('.mdl-header-title a[rel="user"]').text();
  387. follow_button.find('button').click();
  388. toast("Followed <em>1</em> Users!", user_to_follow, "info");
  389. } else if( !follow_button.hasClass('s-following') ){
  390. var attrs = follow_button.attr('class');
  391. toast("I'm Confused!", "What is a <em>" + attrs + "</em>?", "error");
  392. }
  393. follow_container.find('.icon-close').click();
  394. clearInterval(follow_seeker);
  395. }
  396. },50);
  397. }
  398.  
  399. setInterval(function(){
  400. // process all toolbars and tweet locations
  401. for(var key in possible_locations){
  402. var location = possible_locations[key];
  403. $( location.context + ':not([data-ejew])').each(function(){
  404. var grand_dad = $( this );
  405.  
  406. // find all the images and store their links in data
  407. var sources = [];
  408. var media_type = 'idk';
  409. if( grand_dad.find('.is-video').length ){
  410. media_type = 'video';
  411. sources.push( function( e ){
  412. var anchor = grand_dad.find('.js-media-image-link');
  413. var o_target = anchor.attr('target');
  414. var o_src = anchor.attr('src');
  415. anchor.attr('target', '');
  416. anchor.attr('src', '#');
  417. anchor.click();
  418.  
  419. var embeds = lock_find('.js-embeditem');
  420.  
  421. var vid_url = '';
  422. embeds.each(function(){
  423. var iframe_src = $( this ).find( 'iframe' ).attr('src');
  424. if( iframe_src ){
  425. vid_url = getQueryVariable( iframe_src, 'video_url' );
  426. }
  427. $('.mdl-dismiss .icon-close').click();
  428. });
  429.  
  430. anchor.attr('target', o_target);
  431. anchor.attr('src', o_src);
  432.  
  433. if( vid_url.length ){
  434. return vid_url;
  435. }
  436. });
  437. } else if( grand_dad.find('.is-gif').length ){
  438. media_type = 'gif';
  439. sources.push( function(){
  440. return grand_dad.find('video.js-media-gif').attr('src');
  441. });
  442. } else {
  443. var patterns = [
  444. '.js-media-image-link',
  445. 'a.med-link img.media-img',
  446. '.js-media .media-image'
  447. ];
  448. patterns.forEach(function( pattern ){
  449. grand_dad.find( pattern ).each( function(i, el){
  450. var src = $( el ).css('background-image');
  451. if( is_empty( src ) ){
  452. src = $( el ).attr('src');
  453. }
  454. if( !is_empty( src ) ){
  455. sources.push( src );
  456. }
  457. });
  458. });
  459. if( sources.length ){
  460. media_type = 'image';
  461. }
  462. }
  463. var orig_link = grand_dad.find("a.txt-small.no-wrap[rel=\"url\"]");
  464. orig_link.on('click', function(e){
  465. if( e.ctrlKey ){
  466. e.preventDefault();
  467. clipboard_data( $( this ).attr("href") );
  468. }
  469. });
  470. grand_dad.data('ejew-sources', sources );
  471. grand_dad.data('direct-url', orig_link.attr("href"));
  472.  
  473. // enhance stock buttons with auto-follow
  474. grand_dad.find('.icon-retweet').on('click', function(e){
  475. if( e.ctrlKey ){
  476. follow_tweet( grand_dad );
  477. }
  478. });
  479. grand_dad.find('.icon-favorite').on('click', function(e){
  480. if( e.ctrlKey ){
  481. follow_tweet( grand_dad );
  482. }
  483. });
  484.  
  485. // add more buttons
  486. add_to_toolbar( grand_dad, action_properties.download ).on('click', function(e){
  487. var sauce = grand_dad.data('ejew-sources').slice();
  488. console.log( sauce );
  489. var sources = unpack_sources( sauce , this );
  490. console.log( sources );
  491.  
  492. for( var i = 0; i < sources.length; i++ ){
  493. download_now( sources[i] );
  494. }
  495.  
  496. if( sources.length && !sources[0].endsWith("mp4") ){
  497. toast("Downloaded <em>" + sources.length + "</em> Images!", sources.join("\n<br>"), "info");
  498. }
  499.  
  500. if( e.ctrlKey ){
  501. follow_tweet( grand_dad );
  502. }
  503. });
  504. add_to_toolbar( grand_dad, action_properties.hotlink ).on('click', function(e){
  505. var sauce = grand_dad.data('ejew-sources').slice();
  506. console.log( sauce );
  507. var sources = unpack_sources( sauce , this );
  508. console.log( sources );
  509.  
  510. var the_url = grand_dad.data('direct-url');
  511. if( e.ctrlKey && sources.length ){
  512. sources[0] = the_url;
  513. }
  514.  
  515. if( sources.length ){
  516. clipboard_data( sources.join("\n") );
  517. } else {
  518. clipboard_data( the_url );
  519. }
  520. });
  521.  
  522. // prevent loading up this element again
  523. grand_dad.attr('data-ejew', 'in');
  524. });
  525. }
  526.  
  527. // make it so that you can copy image source from previews
  528. $('img.media-img:not([data-ejew])').each(function(){
  529. $( this ).attr('src', nice_url( $( this ).attr('src'), "" ) );
  530. $( this ).attr('data-ejew', 'in');
  531. });
  532.  
  533. // provide a download source link in zoomable previews for videos
  534. $('.js-embeditem:not([data-ejew])').each(function(){
  535. var iframe_src = $( this ).find( 'iframe' ).attr('src');
  536. if( iframe_src ){
  537. var vid_url = getQueryVariable( iframe_src, 'video_url' );
  538. var dl_link = $( '<a href="#">Download Source</a>' );
  539. dl_link.on('click', function(){
  540. download_now( vid_url );
  541. });
  542. $(".med-origlink").after( dl_link );
  543. }
  544. $( this ).attr('data-ejew', 'in');
  545. });
  546.  
  547. // unmask t.co links
  548. var links_to_unmask = $('a[href^="https://t.co/"][data-full-url]');
  549. links_to_unmask.each(function(){
  550. $( this ).attr('href', $( this ).data('full-url') );
  551. });
  552. if( links_to_unmask.length > 0 ){
  553. toast("Unmasked <em>" + links_to_unmask.length + "</em> Links!", "<em>That's a lot!</em>", "info");
  554. }
  555. }, 300);
  556. })();