FcBarca.com Twitter Fix

Skrypt poprawiający osadzanie linków z X.com (Twitter)

// ==UserScript==
// @name        FcBarca.com Twitter Fix
// @namespace   none
// @match       https://www.fcbarca.com/la-rambla*
// @require     https://unpkg.com/[email protected]/dist/tippy.min.js
// @grant       none
// @version     0.3.2
// @author      misterio
// @description Skrypt poprawiający osadzanie linków z X.com (Twitter)
// @license     MIT
// ==/UserScript==

/*jshint esversion: 11 */

//
// STL Extensions
//
Map.prototype.getOrDefault = function(key, defaultValue) {
    return this.has(key) ? this.get(key) : defaultValue;
}

Date.prototype.monthNameList = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ];

Date.prototype.getMonthName = function() {
    return this.monthNameList[this.getMonth()];
};

Date.prototype.getDayOfMonth = function() {
    return this.getDate();
}

Date.prototype.toTwitterDate = function() {
    return `${this.getMonthName()} ${this.getDayOfMonth()}, ${this.getFullYear()}`;
}

//
// jQuery Extensions
//
$.fn.normalizedText = function() {
    return this.text().trim();
};

$.fn.childrenWithText = function(selector) {
    return this.contents().filter(function() {
        if (this.nodeType === Node.ELEMENT_NODE) {
            return $(this).is(selector);
        }

        return this.nodeType === Node.TEXT_NODE;
    });
};

//
// Very simple logger to avoid unnecessary dependency for now...
//
class ConsoleLogger {
    constructor() {}

    debug(msg) {
        console.debug('DEBUG: ' + msg);
    }

    info(msg) {
        console.log('INFO: ' + msg);
    }

    warn(msg) {
        console.warn('WARN: ' + msg);
    }
}
const LOG = new ConsoleLogger();

//
// UI Service
//
class UIService {
    constructor() {
        document.head.removeChild(document.head.firstChild); // Remove <style> added by original tippy script
        document.head.insertAdjacentHTML('afterbegin', this.#createCss()); // Add custom <style>
    }

    registerComponents($commentList) {
        const self = this;

        $commentList.each(function() {
            var $commentNode = $(this);
            if ($commentNode.hasClass('comment') && $commentNode.hasClass('rambla-item')) {
                self.registerComponent($commentNode);
            }
        });

        tippy('div#comments__list li.dynamic__item > button[data-toggle="tooltip"]');
    }

    registerComponent($commentNode) {
        const self = this;
        $commentNode.children('div.comment__meta').find('ul.links').append(self.#createTwitterButton());
    }

    #createTwitterButton() {
        return `
            <li class="links__item dynamic__item">
                <button type="button" data-toggle="tooltip" class="button twitter-button" title="<div class='tooltip-comment'><p>Przełącz widok komentarza</p></div>">
                    <span class="icon icon-active svg-container" aria-hidden="true">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
                            <path fill="#a21d3d" d="M302.973,57.388c-4.87,2.16-9.877,3.983-14.993,5.463c6.057-6.85,10.675-14.91,13.494-23.73
                            c0.632-1.977-0.023-4.141-1.648-5.434c-1.623-1.294-3.878-1.449-5.665-0.39c-10.865,6.444-22.587,11.075-34.878,13.783
                            c-12.381-12.098-29.197-18.983-46.581-18.983c-36.695,0-66.549,29.853-66.549,66.547c0,2.89,0.183,5.764,0.545,8.598
                            C101.163,99.244,58.83,76.863,29.76,41.204c-1.036-1.271-2.632-1.956-4.266-1.825c-1.635,0.128-3.104,1.05-3.93,2.467
                            c-5.896,10.117-9.013,21.688-9.013,33.461c0,16.035,5.725,31.249,15.838,43.137c-3.075-1.065-6.059-2.396-8.907-3.977
                            c-1.529-0.851-3.395-0.838-4.914,0.033c-1.52,0.871-2.473,2.473-2.513,4.224c-0.007,0.295-0.007,0.59-0.007,0.889
                            c0,23.935,12.882,45.484,32.577,57.229c-1.692-0.169-3.383-0.414-5.063-0.735c-1.732-0.331-3.513,0.276-4.681,1.597
                            c-1.17,1.32-1.557,3.16-1.018,4.84c7.29,22.76,26.059,39.501,48.749,44.605c-18.819,11.787-40.34,17.961-62.932,17.961
                            c-4.714,0-9.455-0.277-14.095-0.826c-2.305-0.274-4.509,1.087-5.294,3.279c-0.785,2.193,0.047,4.638,2.008,5.895
                            c29.023,18.609,62.582,28.445,97.047,28.445c67.754,0,110.139-31.95,133.764-58.753c29.46-33.421,46.356-77.658,46.356-121.367
                            c0-1.826-0.028-3.67-0.084-5.508c11.623-8.757,21.63-19.355,29.773-31.536c1.237-1.85,1.103-4.295-0.33-5.998
                            C307.394,57.037,305.009,56.486,302.973,57.388z"></path>
                        </svg>
                    </span>
                  <span class="visuallyhidden">Przełącz widok komentarza</span>
                </button>
            </li>
        `;
    }

    #createCss() {
        return `
            <style type="text/css">
                div#comments__list {
                    & div.comment > div.comment__meta li.dynamic__item {
                        display: none;

                        & .twitter-button {
                            padding: 0;
                            display: block;

                            & .icon {
                                top: -.2rem;
                                color: #8d8d8d;
                                width: 1.5rem;
                                height: 1.5rem;
                                display: block;
                                position: relative;
                            }

                            & .icon-active {
                                color: #a21d3d;
                            }
                        }
                    }

                    & div.comment__fixed > div.comment__meta li.dynamic__item {
                        display: inline-block;
                    }
                }

                @media (max-width: 575.98px) {
                    div#comments__list {
                        & div.comment > div.comment__meta li.dynamic__item .twitter-button .icon {
                            top:-.1rem;
                        }
                    }
                }
            </style>
        `;
    }
}

//
// Twitter Service
//
class TwitterService {
    static TWITTER_REGEXP_EXACT = new RegExp(/^((https?:\/\/)?(x|twitter).com\/(.*?)\/status\/([0-9]+))[^\s]*$/i);
    static TWITTER_REGEXP_PARTIAL = new RegExp(/(x|twitter).com\/.*?\/status\/[0-9]+/i);

    #nodeHandlerMap;
    #originalNodeMap;
    #modifiedNodeMap;

    constructor() {
        const self = this;

        self.#nodeHandlerMap = new Map([
            [Node.ELEMENT_NODE, (node, $commentNode) => self.#reparseTweetsInElementNode(node, $commentNode)],
            [Node.TEXT_NODE, (node, $commentNode) => self.#reparseTweetsInTextNode(node, $commentNode)],
        ]);
        
        self.#originalNodeMap = new Map();
        self.#modifiedNodeMap = new Map();
    }

    reparseTweetsInComments($commentList) {
        const self = this;

        $commentList.each(function() {
            var $commentNode = $(this);
            if ($commentNode.hasClass('comment') && $commentNode.hasClass('rambla-item')) {
                self.reparseTweetsInComment($commentNode);
            }
        });
    }

    reparseTweetsInComment($commentNode) {
        const self = this;
        
        var commentID = $commentNode.attr('data-comment-id');
        LOG.debug('Parsing comment with ID: {' + commentID + '}.');

        var $contentNode = $commentNode.children('.comment__content');
        if ($contentNode.length > 1) {
            throw new Error('Incorrect node size assumption. Expected is {1}, actual was {' + $contentNode.length + '}');
        }

        // Create deep copy of original node (before any modifications)
        self.#originalNodeMap.set(commentID, $contentNode.clone())

        // Phase 1: Try to fix message layout
        var $contentNodeList = $.merge($contentNode.children('p').contents(), $contentNode.childrenWithText(':not(p)'));
        $contentNodeList.each(function() {
            self.#tryFixCommentLayout(this);
        });

        // Phase 2: Embedding unloaded tweets
        var $contentNodeList = $.merge($contentNode.children('p').contents(), $contentNode.childrenWithText(':not(p)'));

        LOG.debug('Comment has {' + $contentNodeList.length + '} node(s) to parse.');
        $contentNodeList.each(function() {
            self.#nodeHandlerMap.getOrDefault(this.nodeType, (node, $commentNode) => self.#reparseTweetsFallback(node, $commentNode))(this, $commentNode);
        });

        // FIXME: This has to be planned some other way... Maybe Controller/ActionService/Presenter?
        if ($commentNode.hasClass('comment__fixed')) {
            $commentNode.children('div.comment__meta').find('li.dynamic__item > button').click(function() {
                var $iconNode = $(this).children('.icon');
                $iconNode.toggleClass('icon-active');

                if ($iconNode.hasClass('icon-active')) {
                    $commentNode.children('div.comment__content').replaceWith(self.#modifiedNodeMap.get(commentID));
                } else {
                    $commentNode.children('div.comment__content').replaceWith(self.#originalNodeMap.get(commentID));
                }
            })
        } else {
            LOG.debug('Remove unused node.');
            self.#originalNodeMap.delete(commentID);
        }
    }

    #tryFixCommentLayout(node) {
        if (node.nodeType !== Node.TEXT_NODE) {
            return; // Only TEXT_NODE need this fix
        }

        var $node = $(node);
        var text = $node.normalizedText();

        if (!TwitterService.TWITTER_REGEXP_PARTIAL.test(text)) {
            return; // There is nothing to correct
        }

        if (TwitterService.TWITTER_REGEXP_EXACT.test(text)) {
            return; // Node is correct
        }

        LOG.debug('Found node that requires correction.');
        var tokenList = text.split(/\s+/).map(function(token) {
            if (TwitterService.TWITTER_REGEXP_EXACT.test(token)) {
                return '<br>' + token + '<br>';
            }

            return token;
        });

        $node.replaceWith(tokenList.join(' '));
    }

    #reparseTweetsInElementNode(node, $commentNode) {
        const self = this;

        var $node = $(node);
        if ($node.hasClass('external-link')) {
            self.#tryReparseTweetsInternal($node.attr('href'), $node, $commentNode);
        }
    }

    #reparseTweetsInTextNode(node, $commentNode) {
        const self = this;

        var $node = $(node);
        self.#tryReparseTweetsInternal($node.normalizedText(), $node, $commentNode);
    }

    #tryReparseTweetsInternal(text, $node, $commentNode) {
        const self = this;

        if (TwitterService.TWITTER_REGEXP_EXACT.test(text)) {
            var matchedGroup = text.match(TwitterService.TWITTER_REGEXP_EXACT);
            var matchedLink = matchedGroup[0];
            var matchedURL = matchedGroup[1].startsWith('http') ? matchedGroup[1] : 'https://' + matchedGroup[1];
            var matchedUsername = matchedGroup[4];
            var matchedTweetID = matchedGroup[5];

            LOG.info('Matched tweet from link: {' + matchedLink + '} with ID: {' + matchedTweetID + '} and URL: {' + matchedURL + '}.');
            $commentNode.addClass('comment__fixed');

            $.ajax({
                url: 'https://publish.twitter.com/oembed?url=' + matchedURL,
                dataType: 'jsonp',
                success: function(data) {
                    LOG.debug('Succeeded at resolving tweet with ID: {' + matchedTweetID + '}.');
                    $node.replaceWith(data.html);
                },
                error: function($xhr, textStatus, errorThrown) {
                    LOG.debug('Failed with: {' + textStatus + '} at resolving tweet with ID: {' + matchedTweetID + '}. Using fallback instead.');
                    $node.replaceWith(self.#buildTweetFallback(matchedTweetID, matchedUsername));
                },
                complete: function($xhr) {
                    LOG.debug('Registering modified node.');
                    self.#modifiedNodeMap.set($commentNode.attr('data-comment-id'), $commentNode.children('.comment__content')); 
                }
            });
        }
    }

    #reparseTweetsFallback(node, $commentNode) {
        LOG.warn('Unhandled NodeType: {' + node.nodeType + '}.');
    }

    #buildTweetFallback(tweetID, username) {
        var currentDate = new Date().toTwitterDate();

        // FIXME: It would be useful to add readable username, but at the moment I have no idea how to do so...
        return `
            <blockquote class="twitter-tweet">
                <p lang="en" dir="ltr">Hmm...this page doesn’t exist. Try searching for something else.</p>
                &mdash; (@${username}) <a href="https://twitter.com/${username}/status/${tweetID}?ref_src=twsrc^tfw">${currentDate}</a>
            </blockquote>
        `;
    }
}

//
// Main Script
//
$(function() {
    LOG.info('Fcbarca.com Twitter Fix Script initialized...');

    var uiService = new UIService();
    var twitterService = new TwitterService();

    // Parse comments that were loaded during sync-request
    var $commentList = $('#comments__list').find('div.comment.rambla-item');
    uiService.registerComponents($commentList);
    twitterService.reparseTweetsInComments($commentList);

    // Create observer to parse comments loaded on async-request
    var observer = new MutationObserver(function(mutationList) {
        mutationList.forEach(function(mutation) {
            var $commentList = $(mutation.addedNodes ?? []);
            uiService.registerComponents($commentList);
            twitterService.reparseTweetsInComments($commentList);
        });
    });

    // Pass in the target node, as well as the observer options
    observer.observe(document.getElementById('comments__list'), {childList: true, subtree: true});
});