TweetDeck Image Assistant

Download/Share Images Faster

질문, 리뷰하거나, 이 스크립트를 신고하세요.
// ==UserScript==
// @name         TweetDeck Image Assistant
// @namespace    http://ejew.in/
// @version      1.1
// @description  Download/Share Images Faster
// @author       EntranceJew
// @match        https://tweetdeck.twitter.com*
// @require      https://cdn.rawgit.com/eligrey/FileSaver.js/5ed507ef8aa53d8ecfea96d96bc7214cd2476fd2/FileSaver.min.js
// @require      https://cdn.rawgit.com/kamranahmedse/jquery-toast-plugin/1105577ed71ef368f8aa3d96295857643dca43d7/dist/jquery.toast.min.js
// @noframes
// @resource    toastCSS https://cdn.rawgit.com/kamranahmedse/jquery-toast-plugin/1105577ed71ef368f8aa3d96295857643dca43d7/dist/jquery.toast.min.css
// @grant       GM_addStyle
// @grant       GM_getResourceText
// ==/UserScript==
/*
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
1.0 - fixed issue with logic in setting default image resolution
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
0.8 - added t.co link unmasking
0.7 - apparently getting gif sources works most reliably inside callbacks
0.6 - hotfix to prevent redundant page reloading with stream-media seek methods
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
0.4 - changed download icon, added copy links button, videos now don't flash their preview, videos no longer close your draft tweets panel
0.3 - gif support wasn't that hard
0.2 - removed debug prints, updated mimes, added video download link, instant-spice now grabs videos
0.1 - initial version
*/

/* # todo:
 * move code into bettertweetdeck
 * make it so that toasts batch by purpose, extending notification times
 * better support for tweet-details view (sometimes causing lockouts?)
 * better support for modal big preview tweets
 * stack quote-tweets
 * do not count quoted media as a media source
 * context menu item insertion
 * context sensitive actions
 * custom action icons / styles
 * update action button tooltips when modifier keys are pressed
 * action to follow all explicitly mentioned users in a tweet (not in the conversation)
 * better tweetdeck integration
 */

(function() {
    'use strict';

    GM_addStyle( GM_getResourceText("toastCSS") );

    var settings = {
        toast: {
            hideAfter: 1000,
        }
    };

    var toast_prototype = {
        text: "Don't forget to star the repository if you like it.", // Text that is to be shown in the toast
        heading: 'Note', // Optional heading to be shown on the toast
        icon: 'success', // Type of toast icon
        showHideTransition: 'slide', // fade, slide or plain
        allowToastClose: true, // Boolean value true or false
        hideAfter: settings.toast.hideAfter, // false to make it sticky or number representing the miliseconds as time after which toast needs to be hidden
        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
        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

        textAlign: 'left',  // Text alignment i.e. left, right or center
        loader: true,  // Whether to show loader or not. True by default
        loaderBg: '#9EC600',  // Background color of the toast loader
        beforeShow: function () {}, // will be triggered before the toast is shown
        afterShown: function () {}, // will be triggered after the toat has been shown
        beforeHide: function () {}, // will be triggered before the toast gets hidden
        afterHidden: function () {}  // will be triggered after the toast has been hidden
    };

    function toast( heading, text, icon ){
        return $.toast(jQuery.extend(true, toast_prototype, {
            heading: heading,
            text: text,
            icon: icon
        }));
    }

    var toolbar_size = 6;
    var tool_icon_width = (1 / toolbar_size) * 100;

    GM_addStyle( `
    .tweet-detail-action-item,
    .without-tweet-drag-handles .tweet-detail-action-item {
        width: ` + tool_icon_width + `% !important;
    }

    .jq-toast-single {
        word-break: break-all;
    }
    `);

    function add_action_style( name, details ){
        var style = `
            .tweet-action:hover .icon-` + details.icon_name +`,
            .tweet-detail-action:hover .icon-` + details.icon_name +`,
            .dm-action:hover .icon-` + details.icon_name +`,
            .tweet-action:focus .icon-` + details.icon_name +`,
            .tweet-detail-action:focus .icon-` + details.icon_name +`,
            .dm-action:focus .icon-` + details.icon_name +`,
            .tweet-action:active .icon-` + details.icon_name +`,
            .tweet-detail-action:active .icon-` + details.icon_name +`,
            .dm-action:active .icon-` + details.icon_name +`,
            .tweet-action.is-selected .icon-` + details.icon_name +`,
            .is-selected.tweet-detail-action .icon-` + details.icon_name +`,
            .is-selected.dm-action .icon-` + details.icon_name +` {
                color: ` + details.color + `;
            }`;
        GM_addStyle( style );
    }

    //.icon-link:before{ content: "\f088"; }
    var action_properties = {
        'download': {
            'name': 'Download',
            'tool_tip': 'Download',
            'rel': 'download',
            'icon_name': 'attachment',
            'before_content': "\f088",
            'color': '#D400E0',
            'on': {
                'click': [
                    ['download_url', ['media_link']],
                    'media_next',
                    ['download_url', ['media_link']],
                    'media_next',
                    ['download_url', ['media_link']],
                    'media_next',
                    ['download_url', ['media_link']],
                    'media_next',
                ],
                'ctrl+click': [
                    'default',
                    'follow'
                ]
            },
        },
        'hotlink': {
            'name': 'Hotlink',
            'tool_tip': 'Hotlink',
            'rel': 'hotlink',
            'icon_name': 'link',
            'before_content': "\f098",
            'color': '#00498A',
            'on': {
                'click': [
                    'clipboard_open',
                    ['clipboard_push', ['media_link']],
                    'media_next',
                    ['clipboard_push', ['media_link']],
                    'media_next',
                    ['clipboard_push', ['media_link']],
                    'media_next',
                    ['clipboard_push', ['media_link']],
                    'media_next',
                    'clipboard_copy'
                ],
                'ctrl+click': [
                    'clipboard_open',
                    ['clipboard_push', ['tweet_link']],
                    'media_next',
                    'media_next',
                    ['clipboard_push', ['media_link']],
                    'media_next',
                    ['clipboard_push', ['media_link']],
                    'media_next',
                    ['clipboard_push', ['media_link']],
                    'media_next',
                    'clipboard_copy'
                ],
            },
        },
    };

    for (var name in action_properties) {
        add_action_style(name, action_properties[name]);
    }

    var possible_locations = {
        'single': {
            'context': '.tweet-detail-wrapper > article.stream-item',
            //'toolbar': 'tweet-detail-actions',
            //'item': 'tweet-detail-action-item',
            'filters': [
                ":not('.feature-customtimelines')",
                ":last"
            ],
            'images': [
                'img.media-img',
            ],
        },
        'normal': {
            'context': 'article.stream-item',
            //'toolbar': 'tweet-actions',
            //'item': 'tweet-action-item',
            'filters': [
                ":not('.feature-customtimelines')",
                ":last"
            ]
        },
        'modal': {
            'context': 'div#open-modal div.item-box',
            //'toolbar':
            //
            'filters': []
        }
    };

    function add_to_toolbar( context = 'html', action = action_properties.download, location = possible_locations.normal ){
        // var slugged = action.name.replace(/\s/g, '').toLowerCase();

        // clone reply
        var x = $( context ).find( "ul[class*=-actions] > li:first" ).clone();

        // set the attributes
        x.find('a')
            .removeClass('js-reply-action')
            .addClass('js-'+action.rel+'-action')
            .addClass('js-show-tip')
            .attr('rel', action.rel )
            .attr('title', action.tool_tip )
            .data('original-title', action.tool_tip );

        x.find('i')
            .removeClass('icon-reply')
            .addClass('icon-'+action.icon_name)
            .addClass('icon-'+action.icon_name+'-toggle');

        x.find('span.is-vishidden')
            .text( action.tool_tip );

        // plant it back at the end
        var z = $( context ).find( "ul[class*=-actions] > li" );
        location.filters.forEach(function(element){
            z = $( z ).filter( element );
        });
        z = $( z ).prev();
        $( x ).insertAfter( $(z) );

        return $( x );
    }

    var mime_db = {
        jpeg: "image/jpeg",
        jpg: "image/jpeg",
        gif: "image/gif",
        webp: "image/webp",
        mp4: "video/mp4",
        m3u8: "application/x-mpegURL",
        undefined: "text/plain"
    };

    function clipboard_data( text ){
        var tc = $('.compose-text-container .js-compose-text');
        var orig = tc.val();
        var active = document.activeElement;
        tc.val( text );
        tc[0].focus();
        tc[0].setSelectionRange( 0, text.length );
        document.execCommand("copy");
        tc.val( orig );
        active.focus();
        toast("Copied <em>" + text.split(/\r*\n/).length + "</em> Lines!", text.replace(/\r*\n/, "<br>"), "info");
    }

    // http://stackoverflow.com/a/2091331
    function getQueryVariable(str, variable) {
        var query = str.substring(1);
        var vars = query.split('&');
        for (var i = 0; i < vars.length; i++) {
            var pair = vars[i].split('=');
            if (decodeURIComponent(pair[0]) == variable) {
                return decodeURIComponent(pair[1]);
            }
        }
        console.log('Query variable %s not found', variable);
    }

    function detect_mime(url){
        return mime_db[ /(?:\.([^.]+))?$/.exec(url)[1] ];
    }

    function get_img_data( url, on_load ) {
        var xhr = new XMLHttpRequest();
        xhr.open("GET", url);
        xhr.responseType = "blob";
        xhr.onload = on_load;
        xhr.send();
    }

    function is_empty( str ){
        return !(str && typeof(str) == "string" && str.trim().length !== 0 && str !== "none");
    }

    function unique(array) {
        return $.grep(array, function(el, index) {
            return index === $.inArray(el, array);
        });
    }

    function unpack_sources( packed_sources, context ){
        var unpacked = [];
        for( var str in packed_sources ){
            var src = packed_sources[str];
            if( typeof(src) === "function" ){
                src = src( context );
            }
            if( !is_empty( src ) ){
                unpacked.push( nice_url( src ) );
            }
        }
        return unique(unpacked);
    }

    function download_now( url, prefix = "twitter_" ){
        if( url.length ){
            get_img_data( url, function( e ){
                var img_name = url.substring( url.lastIndexOf('/')+1 );
                var the_blob = new Blob([this.response], {type: detect_mime(url)});
                var save_file_name = img_name.replace(/:orig$/, "");
                saveAs( the_blob, prefix + save_file_name );
                if( save_file_name.endsWith('mp4') ){
                    toast("Downloaded <em>1</em> Video!", save_file_name, "info");
                }
            });
        }
    }

    function nice_url( url, replacement ){
        if( replacement === "" ){
            // whatever
        } else if( !replacement ){
            replacement = ":orig";
        }

        var bg = url;
        bg = bg.replace('url(','').replace(')','').replace(/\"/gi, "");
        bg = bg.replace(/:thumb$/, replacement);
        bg = bg.replace(/:small$/, replacement);
        bg = bg.replace(/:medium$/, replacement);
        bg = bg.replace(/:large$/, replacement);
        return bg;
    }

    // danger: this could potentially lockup if the element isn't guaranteed to appear.
    function lock_find( selector, context ){
        var results = $( selector, context );
        while( !results.length ){
            results = $( selector, context );
        }
        return results;
    }

    // we have to do literal jungle japes in order to get to the follow button from here
    // strap in
    function follow_tweet( selector ){
        $( selector ).find( "ul[class*=-actions] > li:not(.feature-customtimelines) i.icon-more" ).filter(":last").click();
        var column_owner = $( selector ).parents('.column-panel').find('h1.column-title span.attribution').text();
        var more = lock_find('.js-dropdown.dropdown-menu a[data-action="followOrUnfollow"]', $( selector ));
        $( more ).parent('li.is-selectable').addClass('is-selected');
        $( more ).click();
        var follow_container = lock_find('div.js-modal-context');
        var column_owner_follow = null;

        // entrancejew only follows from his third account
        // entrancejew also refuses to implement settings yet
        if( column_owner == "@EntranceJew" ){
            column_owner_follow = lock_find('div.js-follow-from:nth-child(3)', follow_container);
        } else {
            follow_container.find('.js-from-username').each(function(){
                var this_name = $( this ).text();
                if( this_name.includes( column_owner ) ){
                    column_owner_follow = $( this ).parent('.js-follow-from');
                }
            });
        }

        var follow_button = null;
        var follow_seeker = setInterval(function(){
            follow_button = column_owner_follow.find('.js-action-follow[class*=" s-"]');
            if( follow_button.length ){
                if( follow_button.hasClass('s-not-following') ){
                    var user_to_follow = $('.mdl-header-title a[rel="user"]').text();
                    follow_button.find('button').click();
                    toast("Followed <em>1</em> Users!", user_to_follow, "info");
                } else if( !follow_button.hasClass('s-following') ){
                    var attrs = follow_button.attr('class');
                    toast("I'm Confused!", "What is a <em>" + attrs + "</em>?", "error");
                }
                follow_container.find('.icon-close').click();
                clearInterval(follow_seeker);
            }
        },50);
    }

    setInterval(function(){
        // process all toolbars and tweet locations
        for(var key in possible_locations){
            var location = possible_locations[key];
            $( location.context + ':not([data-ejew])').each(function(){
                var grand_dad = $( this );

                // find all the images and store their links in data
                var sources = [];
                var media_type = 'idk';
                if( grand_dad.find('.is-video').length ){
                    media_type = 'video';
                    sources.push( function( e ){
                        var anchor = grand_dad.find('.js-media-image-link');
                        var o_target = anchor.attr('target');
                        var o_src = anchor.attr('src');
                        anchor.attr('target', '');
                        anchor.attr('src', '#');
                        anchor.click();

                        var embeds = lock_find('.js-embeditem');

                        var vid_url = '';
                        embeds.each(function(){
                            var iframe_src = $( this ).find( 'iframe' ).attr('src');
                            if( iframe_src ){
                                vid_url = getQueryVariable( iframe_src, 'video_url' );
                            }
                            $('.mdl-dismiss .icon-close').click();
                        });

                        anchor.attr('target', o_target);
                        anchor.attr('src', o_src);

                        if( vid_url.length ){
                            return vid_url;
                        }
                    });
                } else if( grand_dad.find('.is-gif').length ){
                    media_type = 'gif';
                    sources.push( function(){
                        return grand_dad.find('video.js-media-gif').attr('src');
                    });
                } else {
                    var patterns = [
                        '.js-media-image-link',
                        'a.med-link img.media-img',
                        '.js-media .media-image'
                    ];
                    patterns.forEach(function( pattern ){
                        grand_dad.find( pattern ).each( function(i, el){
                            var src = $( el ).css('background-image');
                            if( is_empty( src ) ){
                                src =  $( el ).attr('src');
                            }
                            if( !is_empty( src ) ){
                                sources.push( src );
                            }
                        });
                    });
                    if( sources.length ){
                        media_type = 'image';
                    }
                }
                var orig_link = grand_dad.find("a.txt-small.no-wrap[rel=\"url\"]");
                orig_link.on('click', function(e){
                    if( e.ctrlKey ){
                        e.preventDefault();
                        clipboard_data( $( this ).attr("href") );
                    }
                });
                grand_dad.data('ejew-sources', sources );
                grand_dad.data('direct-url', orig_link.attr("href"));

                // enhance stock buttons with auto-follow
                grand_dad.find('.icon-retweet').on('click', function(e){
                    if( e.ctrlKey ){
                        follow_tweet( grand_dad );
                    }
                });
                grand_dad.find('.icon-favorite').on('click', function(e){
                    if( e.ctrlKey ){
                        follow_tweet( grand_dad );
                    }
                });

                // add more buttons
                add_to_toolbar( grand_dad, action_properties.download ).on('click', function(e){
                    var sauce = grand_dad.data('ejew-sources').slice();
                    console.log( sauce );
                    var sources = unpack_sources( sauce , this );
                    console.log( sources );

                    for( var i = 0; i < sources.length; i++ ){
                        download_now( sources[i] );
                    }

                    if( sources.length && !sources[0].endsWith("mp4") ){
                        toast("Downloaded <em>" + sources.length + "</em> Images!", sources.join("\n<br>"), "info");
                    }

                    if( e.ctrlKey ){
                        follow_tweet( grand_dad );
                    }
                });
                add_to_toolbar( grand_dad, action_properties.hotlink ).on('click', function(e){
                    var sauce = grand_dad.data('ejew-sources').slice();
                    console.log( sauce );
                    var sources = unpack_sources( sauce , this );
                    console.log( sources );

                    var the_url = grand_dad.data('direct-url');
                    if( e.ctrlKey && sources.length ){
                        sources[0] = the_url;
                    }

                    if( sources.length ){
                        clipboard_data( sources.join("\n") );
                    } else {
                        clipboard_data( the_url );
                    }
                });

                // prevent loading up this element again
                grand_dad.attr('data-ejew', 'in');
            });
        }

        // make it so that you can copy image source from previews
        $('img.media-img:not([data-ejew])').each(function(){
            $( this ).attr('src', nice_url( $( this ).attr('src'), "" ) );
            $( this ).attr('data-ejew', 'in');
        });

        // provide a download source link in zoomable previews for videos
        $('.js-embeditem:not([data-ejew])').each(function(){
            var iframe_src = $( this ).find( 'iframe' ).attr('src');
            if( iframe_src ){
                var vid_url = getQueryVariable( iframe_src, 'video_url' );
                var dl_link = $( '<a href="#">Download Source</a>' );
                dl_link.on('click', function(){
                    download_now( vid_url );
                });
                $(".med-origlink").after( dl_link );
            }
            $( this ).attr('data-ejew', 'in');
        });

        // unmask t.co links
        var links_to_unmask = $('a[href^="https://t.co/"][data-full-url]');
        links_to_unmask.each(function(){
            $( this ).attr('href', $( this ).data('full-url') );
        });
        if( links_to_unmask.length > 0 ){
            toast("Unmasked <em>" + links_to_unmask.length + "</em> Links!", "<em>That's a lot!</em>", "info");
        }
    }, 300);
})();