4chan-IDficator

Allow users to bring IDs into boards that does not have them.

// ==UserScript==
// @name        4chan-IDficator
// @namespace   Violentmonkey Scripts
// @match       *://boards.4chan.org/*
// @grant       none
// @version     0.51
// @author      Doomkek
// @include     http://boards.4chan.org/*
// @include     https://boards.4chan.org/*
// @include     http://sys.4chan.org/*
// @include     https://sys.4chan.org/*
// @include     http://www.4chan.org/*
// @include     https://www.4chan.org/*
// @include     http://boards.4channel.org/*
// @include     https://boards.4channel.org/*
// @include     http://sys.4channel.org/*
// @include     https://sys.4channel.org/*
// @include     http://www.4channel.org/*
// @include     https://www.4channel.org/*
// @connect     https://giggers.moe
// @description Allow users to bring IDs into boards that does not have them.
// @license     MIT
// @homepageURL https://github.com/doomkek/4chan-IDficator
// ==/UserScript==

(function () {
    var SERVICE_URL = "https://giggers.moe";
    var IS_4CHANX = false;
    var menuNode;

    const config = {
        USE_ID: true,

        saveConfig: function () { localStorage.setItem("4chan-id.config", JSON.stringify(config)); },
        loadConfig: () => {
            var conf = JSON.parse(localStorage.getItem("4chan-id.config"))
            if (!conf)
                return;

            for (const key in config) {
                if (conf.hasOwnProperty(key)) {
                    config[key] = conf[key];
                }
            }
        },
    };

    const filters = {
        hiddenIDs: [],
        saveFilters: () => {
            var storageFilters = JSON.parse(localStorage.getItem("4chan-id.filters"));
            if (storageFilters == null) {
                localStorage.setItem("4chan-id.filters", JSON.stringify(filters.hiddenIDs));
            } else {
                var otherFilters = storageFilters.filter(p => p.boardId != thread.boardId && p.threadId != thread.threadId);
                localStorage.setItem("4chan-id.filters", JSON.stringify(otherFilters.concat(filters.hiddenIDs)));
            }
        },
        loadFilters: () => {
            var storageFilters = JSON.parse(localStorage.getItem("4chan-id.filters"));
            if (storageFilters != null) { //                                                                                                        2 weeks 
                filters.hiddenIDs = storageFilters.filter(p => p.boardId == thread.boardId && p.threadId == thread.threadId && Date.now() - p.ts < 14 * 24 * 60 * 60 * 1000);
            }
        },
        addFilter: (userId) => {
            if (filters.isUserIdFiltered(userId))
                return;

            filters.hiddenIDs.push({ boardId: thread.boardId, threadId: thread.threadId, userId: userId, ts: Date.now() });
            filters.saveFilters();
        },
        removeFilter: (userId) => {
            var index = filters.hiddenIDs.findIndex(p => p.userId == userId);
            if (index != -1) {
                filters.hiddenIDs.splice(index, 1);
                filters.saveFilters();
            }
        },
        isUserIdFiltered: (userId) => filters.hiddenIDs.some(p => p.userId == userId),
        changePostHideState: function (postId, hide) {
            if (IS_4CHANX) {
                var post = document.getElementById('sa' + postId);
                if (hide && document.getElementById('pc' + postId).querySelector('.stub') == null || !hide && document.getElementById('pc' + postId).querySelector('.stub') != null)
                    document.getElementById('sa' + postId).querySelector('.hide-reply-button').click();
            } else {
                var post = document.getElementById('pc' + postId);
                if (hide && !post.classList.contains("post-hidden") || !hide && post.classList.contains("post-hidden"))
                    post.classList.toggle("post-hidden");
            }
        }
    };

    const thread = {
        boardId: Main.board,
        threadId: Main.tid,
        posts: [],

        init: function () {
            config.loadConfig();
            filters.loadFilters();
            menu.createMenu();
            api.getShitposts(data => thread.applyShitposts(data));
        },

        getPostById: (postId) => thread.posts.find(p => p.postId == postId),
        getPostsByUserId: (userId) => thread.posts.filter(p => p.userId == userId),

        applyShitposts: function (data) {
            //TODO: need to check all local posts as well, because one of them could've been deleted on server side 
            for (var i = 0; i < data.length; i++) {
                var { postId: postId, userHash: userId } = data[i];
                var post = thread.getPostById(postId);
                if (!post) {
                    post = {
                        postId,
                        userId,
                        // this element could be missing sometimes, because giggers service is faster than 4chan
                        // that lead to scrip thinking that this post already exists in DOM because giggers service told so 
                        // but it does not, so have to check it every time before using it
                        rootElement: document.getElementById('pc' + postId),
                        idElement: null,
                        setIdElement: function (element) {
                            if (!this.rootElement)
                                return;

                            var p = this.rootElement.querySelector('.postNum.desktop');
                            p.insertAdjacentElement('afterend', this.idElement = element);
                        },
                        scrollIntoView: function () {
                            if (!this.rootElement)
                                return;

                            this.idElement.scrollIntoView({ behavior: 'instant', block: 'center' })
                        },
                        showMenu: function () {
                            menuNode.id = "menu_" + this.postId + "_" + this.userId;
                            menuNode.style.left = Math.round(this.idElement.getBoundingClientRect().left) + "px";
                            this.idElement.insertAdjacentElement('afterend', menuNode);
                        },
                        isHidden: function () {
                            if (!this.rootElement)
                                return true;

                            if (this.rootElement.querySelector('.stub'))
                                return true;

                            if (this.rootElement.classList.contains('post-hidden'))
                                return true;

                            if (this.rootElement.hasAttribute('hidden'))
                                return true;

                            return false;
                        }
                    }

                    thread.posts.push(post);
                    thread.applyId(post);
                } else if (!post.rootElement) {
                    post.rootElement = document.getElementById('pc' + postId);
                    thread.applyId(post);
                }

                if (filters.isUserIdFiltered(userId))
                    filters.changePostHideState(postId, true);
            }
        },
        applyId: function (post) {
            var a = document.createElement('span');
            a.id = "shitpost_" + post.postId
            a.className = post.userId;
            a.innerText = post.userId;
            a.style.backgroundColor = "#" + post.userId;
            a.style.color = "white";
            a.style.textShadow = "black 0.5px 0.5px";
            a.style.marginLeft = "4px";
            a.style.cursor = "pointer";
            a.style.paddingRight = a.style.paddingLeft = "6px";
            a.style.borderRadius = "10px";

            a.onmouseenter = function (e) {
                var posts = thread.getPostsByUserId(post.userId);
                a.title = posts.length + (posts.length > 1 ? " posts" : " post") + " by this ID";
            };

            a.onmouseleave = function (e) { a.title = ""; };

            a.onclick = function (e) {
                post.showMenu();

                if (menuNode.style.display == "block") {
                    menuNode.style.display = "none";
                    document.removeEventListener('click', menu.handleMenuClick);
                } else {
                    menuNode.style.display = "block";
                    document.addEventListener('click', menu.handleMenuClick);
                }
            };

            post.setIdElement(a);
        }
    };

    const menu = {
        createMenu: function () {
            if (menuNode)
                menuNode.remove();

            menuNode = document.createElement('div');
            menuNode.style.width = "80px";
            menuNode.style.position = "absolute";
            menuNode.style.boxShadow = "0 1px 2px rgba(0, 0, 0, .15)";
            menuNode.style.background = IS_4CHANX ? "var(--fcsp-background)" : "#b7c5d9";
            menuNode.style.color = IS_4CHANX ? "var(--fcsp-text)" : "#000";
            menuNode.style.border = IS_4CHANX ? "1px solid var(--fcsp-border)" : "1px solid #b7c5d9";
            menuNode.style.display = "none";
            menuNode.getPostId = function () { return menuNode.id.split('_')[1]; }
            menuNode.getUserId = function () { return menuNode.id.split('_')[2]; }

            menu.insertMenuItem("Highlight", function () {
                var posts = document.getElementsByClassName(menuNode.getUserId());
                for (var i = 0; i < posts.length; i++) {
                    var parentId = posts[i].id.split('_')[1];
                    var parent = document.getElementById('p' + parentId);
                    parent.classList.toggle("highlight");
                }
            });

            menu.insertMenuItem("↑ Prev", function () {
                var id = menuNode.getPostId();
                var posts = thread.getPostsByUserId(menuNode.getUserId());
                for (var i = posts.length - 1; i >= 0; i--) {
                    if (i == 0) {
                        var nextPost = posts.findLast(p => !p.isHidden());
                        if (nextPost) {
                            nextPost.showMenu();
                            nextPost.scrollIntoView();
                        }
                    }

                    if (posts[i].isHidden()) {
                        continue;
                    }

                    if (posts[i].postId < id) {
                        posts[i].showMenu();
                        posts[i].scrollIntoView();
                        break;
                    }
                }
            });

            menu.insertMenuItem("↓ Next", function () {
                var postId = menuNode.getPostId();
                var posts = thread.getPostsByUserId(menuNode.getUserId());
                for (var i = 0; i < posts.length; i++) {
                    if (i + 1 == posts.length) {
                        var nextPost = posts.find(p => !p.isHidden());
                        if (nextPost) {
                            nextPost.showMenu();
                            nextPost.scrollIntoView();
                        }
                    }

                    if (posts[i].isHidden()) {
                        continue;
                    }

                    if (posts[i].postId > postId) {
                        posts[i].showMenu();
                        posts[i].scrollIntoView();
                        break;
                    }
                }
            });

            var toggleHide = function (hide) {
                var userId = menuNode.getUserId();
                var posts = thread.getPostsByUserId(userId);

                if (hide) {
                    filters.addFilter(userId);
                } else {
                    filters.removeFilter(userId);
                }

                for (var i = 0; i < posts.length; i++) {
                    filters.changePostHideState(posts[i].postId, hide);
                }
            }

            menu.insertMenuItem("Hide ID", () => { toggleHide(true); menuNode.style.display = "none"; });
            menu.insertMenuItem("Unhide ID", () => { toggleHide(false); menuNode.style.display = "none"; });
        },
        handleMenuClick: function (e) {
            if (!e.target.id.startsWith('shitpost_') && !e.target.id.startsWith('menu_item_') && menuNode.style.display == "block" && e.target != menuNode) {
                menuNode.style.display = "none";
                document.removeEventListener('click', menu.handleMenuClick);
            }
        },
        insertMenuItem: function (name, handler) {
            var menuItem = document.createElement('div');
            menuItem.innerText = name;
            menuItem.id = "menu_item_" + name.replace(' ', '');
            menuItem.style.height = IS_4CHANX ? "21px" : "18px";
            menuItem.style.cursor = "pointer";
            menuItem.style.paddingLeft = "4px";
            menuItem.style.color = IS_4CHANX ? "var(--fcsp-text)" : "#000";
            menuItem.style.background = IS_4CHANX ? "var(--fcsp-background)" : "#d6daf0";
            menuItem.style.userSelect = "none";

            menuItem.addEventListener('mouseover', function () { menuItem.style.background = IS_4CHANX ? "var(--fcsp-border)" : "#eef2ff"; });
            menuItem.addEventListener('mouseout', function () { menuItem.style.background = IS_4CHANX ? "var(--fcsp-background)" : "#d6daf0"; });
            menuItem.addEventListener('click', handler);

            menuNode.appendChild(menuItem);
        }
    };

    const api = {
        getShitposts: function (callback) {
            const url = `${SERVICE_URL}/getShitposts/${thread.boardId}/${thread.threadId}`;

            fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
                .then(response => response.json())
                .then(data => callback(data));
        },
        addPost: function (postId) {
            if (!config.USE_ID)
                return;

            fetch(`${SERVICE_URL}/addPost?boardId=${thread.boardId}&threadId=${thread.threadId}&postId=${postId}`, { method: 'POST' });
        }
    };

    const utils = {};

    document.addEventListener('4chanXInitFinished', function (e) {
        IS_4CHANX = true;
        thread.init();
    });

    document.addEventListener('4chanThreadUpdated', function (e) { api.getShitposts(data => thread.applyShitposts(data)); });
    document.addEventListener('ThreadUpdate', function (e) {
        // can't think of a better way to detect 4chanX userscript
        //TODO: need to react to this change, like if it was false but now true then reinit menu? is it enough?
        if (!IS_4CHANX && (IS_4CHANX = document.getElementsByClassName('fcsp-chan-x-controls').length > 0)) {
            console.log("4chan-x detected");
            menu.createMenu();
        }

        if (e.detail.newPosts.length == 0)
            return;
        api.getShitposts(data => thread.applyShitposts(data));
    });

    document.addEventListener('QRPostSuccessful', function (e) { api.addPost(e.detail.postID); });
    document.addEventListener('4chanQRPostSuccess', function (e) { api.addPost(e.detail.postId); });

    // need for tracking non 4chan-x QR being added to the DOM
    var doom = function (e) {
        if (e.target.id != "quickReply")
            return;

        var cbID = document.createElement('span');
        cbID.id = 'cbID';
        cbID.innerHTML = '<label>[<input type="checkbox" checked="' + config.USE_ID + '" name="cbID">Use ID?]</label>';
        cbID.addEventListener("change", function (e) {
            config.USE_ID = cbID.querySelector('input').checked;
            config.saveConfig();
        });
        e.target.querySelector('#qrSpoiler').insertAdjacentElement('afterend', cbID);
        cbID.querySelector('input').checked = config.USE_ID;
    };

    document.addEventListener('QRDialogCreation', function (e) {
        var cbID = document.createElement('label');
        cbID.id = 'cbID';
        cbID.innerHTML = '<input type="checkbox" checked="' + config.USE_ID + '" name="cbID">Use ID?</input>';
        cbID.addEventListener("change", function (e) {
            config.USE_ID = cbID.querySelector('input').checked;
            config.saveConfig();
        });

        e.target.querySelector('.move').appendChild(cbID);
        cbID.querySelector('input').checked = config.USE_ID;
        document.getElementsByTagName('body')[0].removeEventListener('DOMNodeInserted', doom);
    });

    document.getElementsByTagName('body')[0].addEventListener("DOMNodeInserted", doom);

    thread.init();
})();