Meguca Extended

Adds new functionality to Meguca/shamichan imageboards

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Meguca Extended
// @namespace    meguca.shamichan.ext
// @version      1.1.2
// @description  Adds new functionality to Meguca/shamichan imageboards
// @author       SaddestPanda
// @license      UNLICENSE
// @match        https://2chen.moe/*
// @match        https://sturdychan.help/*
// @match        https://shamik.ooo/*
// @match        https://shamiko.org/*
// @match        https://meta.4chan.gay/*
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.deleteValue
// @grant        GM.registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(async function () {
    "use strict";

    // Default settings
    const defaultSettings = {
        scrollToUnread: true,
        showScrollbarMarkers: true,
        firstRun: true,
    };

    const settings = {
        scrollToUnread: await GM.getValue("scrollToUnread", defaultSettings.scrollToUnread),
        showScrollbarMarkers: await GM.getValue("showScrollbarMarkers", defaultSettings.showScrollbarMarkers),
        firstRun: await GM.getValue("firstRun", defaultSettings.firstRun),
    };

    console.log("%cMeguca Extended: Started with settings:", "color:rgb(0, 140, 255)", settings);

    addMyStyle("meguca-extended-css", `
    .lastRead {
        border-top: 8px solid #1cb9d2;
    }

    .marker-container {
        position: fixed;
        top: 0;
        right: 0;
        width: 10px;
        height: 100vh;
        z-index: 1000;
        pointer-events: none;
    }

    .marker {
        position: absolute;
        width: 100%;
        height: 6px;
        background: #0092ff;
        cursor: pointer;
        pointer-events: auto;
        border-radius: 40% 0 0 40%;
        z-index: 5;
    }

    .marker.alt {
        background: #a8d8f8;
        z-index: 2;
    }

    #megucaExtendedMenu {
        position: fixed;
        top: 15px;
        right: 100px;
        padding: 10px;
        z-index: 10000;
        font-family: Arial, sans-serif;
        font-size: 14px;
        box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
        background: #353535;
        border: 1px solid #737373;
        color: #ddd;
        border-radius: 4px;
    }

    .postMine {
        border-left: 3px dashed;
        border-left-color: #36a9ffed !important;
        padding-left: 7px;
        box-sizing: border-box;
    }

    .postReply:not(.postMine) {
        border-left: 2px solid;
        border-left-color: #a8d8f8b0 !important;
        padding-left: 8px;
        box-sizing: border-box;
    }
    `);

    // Register menu command
    GM.registerMenuCommand("Show Options Menu", openMenu);
    try {
        createSettingsButton();
    } catch (error) {
        console.log("Error while creating settings button:", error);
    }

    //Open the settings menu on the first run
    if (settings.firstRun) {
        settings.firstRun = false;
        await GM.setValue("firstRun", settings.firstRun);
        openMenu();
    }

    let threadID = document.querySelector("#thread-container")?.dataset.id || null;
    if (!threadID) {
        console.log("Meguca Extended: Thread ID is empty. Is this even a thread? Exiting.");
        return;
    }
    let threadPosts = document.querySelectorAll("#threads #thread-container article");

    // if (threadPosts?.length < 10) {
    //     //disable if there are less than 10 posts
    //     return;
    // }

    const yourPosts = [];
    const yourReplies = [];
    let db;
    let retries = 0;
    dbStart();

    function openMenu() {
        const oldMenu = document.getElementById("megucaExtendedMenu");
        if (oldMenu) {
            oldMenu.remove();
            return;
        }
        // Create options menu
        const menu = document.createElement("div");
        menu.id = "megucaExtendedMenu";
        menu.innerHTML = `
            <h3 style="text-align: center; color:#6bc9ff;">Meguca Extended Options</h3><br><br>
            <label>
                <input type="checkbox" id="scrollToUnread" ${settings.scrollToUnread ? "checked" : ""}>
                Scroll to first unread post after page load
            </label><br>
            <label>
                <input type="checkbox" id="showScrollbarMarkers" ${settings.showScrollbarMarkers ? "checked" : ""}>
                Show your posts and replies on the scrollbar
            </label><br><br>
            <button id="saveSettings">Save</button>
            <button id="closeMenu">Close</button>
        `;
        document.body.appendChild(menu);

        // Save button functionality
        document.getElementById("saveSettings").addEventListener("click", async () => {
            settings.scrollToUnread = document.getElementById("scrollToUnread").checked;
            settings.showScrollbarMarkers = document.getElementById("showScrollbarMarkers").checked;

            await GM.setValue("scrollToUnread", settings.scrollToUnread);
            await GM.setValue("showScrollbarMarkers", settings.showScrollbarMarkers);

            alert("Settings saved!\nRefresh the page for the changes to take effect.");
            menu.remove();
        });

        // Close button functionality
        document.getElementById("closeMenu").addEventListener("click", () => {
            menu.remove();
        });
    }

    function createSettingsButton() {
        document.querySelector(".overlay-container #banner-watcher").parentElement.insertAdjacentHTML("beforeend", `
        <a id="banner-megucaextended" class="banner-float svg-link noscript-hide" title="Meguca Extended Settings"
            style="color: #2eb1ff;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
                <path
                    d="M352 320c88.4 0 160-71.6 160-160c0-15.3-2.2-30.1-6.2-44.2c-3.1-10.8-16.4-13.2-24.3-5.3l-76.8 76.8c-3 3-7.1 4.7-11.3 4.7L336 192c-8.8 0-16-7.2-16-16l0-57.4c0-4.2 1.7-8.3 4.7-11.3l76.8-76.8c7.9-7.9 5.4-21.2-5.3-24.3C382.1 2.2 367.3 0 352 0C263.6 0 192 71.6 192 160c0 19.1 3.4 37.5 9.5 54.5L19.9 396.1C7.2 408.8 0 426.1 0 444.1C0 481.6 30.4 512 67.9 512c18 0 35.3-7.2 48-19.9L297.5 310.5c17 6.2 35.4 9.5 54.5 9.5zM80 408a24 24 0 1 1 0 48 24 24 0 1 1 0-48z">
                </path>
            </svg>
        </a>
        `);
        document.querySelector("#banner-megucaextended").addEventListener("click", openMenu);
    }

    function dbStart() {
        let indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
        let DBOpenRequest = indexedDB.open("meguca");
        DBOpenRequest.onsuccess = (event) => {
            db = event.target.result;
            dbContinue();
        };
        DBOpenRequest.onerror = (event) => {
            //retry db access
            if (retries < 5) {
                retries++;
                setTimeout(() => {
                    dbStart();
                }, 150);
            }
        };
    }

    async function dbContinue() {
        //Always mark unread, the setting is just for the scrolling part.
        let transaction = db.transaction("seenPost", "readonly");
        let objectStore = transaction.objectStore("seenPost");
        let getAll = objectStore.getAll();
        getAll.onsuccess = (event) => {
            let allData = event.target.result;

            //Find "first unread post"
            let postIDs = new Map();
            threadPosts.forEach((element) => {
                let id = parseInt(element.id.split("p")[1]);
                postIDs.set(id, true);
            });

            allData.forEach((obj) => {
                if (obj.op == threadID) {
                    postIDs.delete(obj.id);
                }
            });

            if (postIDs.size == 0) {
                //No unread posts. Scroll to bottom.
                document.querySelector("html").scrollIntoView(false);
            } else {
                //Scroll to first unread post
                const iterator = postIDs.keys();
                let firstUnreadID = iterator.next().value;
                let firstUnreadElem = document.querySelector(`article[id="p${firstUnreadID}"]`);
                if (firstUnreadElem) {
                    //Mark as read (add styling)
                    firstUnreadElem.classList.add("lastRead");
                    //Do scroll (top of next elem)
                    if (settings.scrollToUnread) {
                        let firstUnreadPos = findPos(firstUnreadElem?.nextElementSibling || firstUnreadElem);
                        window.scroll(0, firstUnreadPos.top - window.innerHeight);
                    }
                }
            }

            /*
            //Find "last read post"
            //This method doesn't work as hovered backlinks are set to read as well

            let filteredData = allData.filter(obj => obj.op == threadID);
            let lastObj = filteredData[filteredData.length - 1];
            let lastReadElem = document.querySelector(`article[id="p${lastObj.id}"]`);
            //Mark as read (add styling)
            lastReadElem.classList.add("lastRead");
            //Scroll one screen height above last read (don't show last read)
            let lastReadPos = findPos(lastReadElem);
            window.scroll(0, lastReadPos.top - window.innerHeight + 150); //+N is to show last read post and part of the next post 
            */
        };
        getAll.onerror = (event) => {
            console.error("Meguca Extended: Error accessing 'seenPost' object store:", event);
            db.close();
        };

        //Always mark posts, the setting is just for the scrollbar marks
        let mineTransaction = db.transaction("mine", "readonly");
        let mineObjectStore = mineTransaction.objectStore("mine");
        let getAllMine = mineObjectStore.getAll();
        getAllMine.onsuccess = (mineEvent) => {
            //Close db early
            db.close();

            let mineData = mineEvent.target.result;

            // Create marker container
            const markerContainer = document.createElement("div");
            if (settings.showScrollbarMarkers) {
                markerContainer.classList.add("marker-container");
                document.body.appendChild(markerContainer);
            }
            // Filter and log matching "op" values
            mineData.forEach((obj) => {
                if (obj.op == threadID) {
                    let postMine = document.querySelector(`article[id="p${obj.id}"]`);
                    if (postMine) {
                        postMine.classList.add("postMine");
                        yourPosts.push(obj.id);
                        let postReplyLinks = postMine.querySelectorAll(".backlinks a[data-id]");
                        postReplyLinks.forEach((link) => {
                            let postReply = document.querySelector(`article[id="p${link.dataset.id}"]`);
                            if (postReply) {
                                yourReplies.push(link.dataset.id);
                                postReply.classList.add("postReply");
                            }
                        });
                    }
                }
            });

            if (settings.showScrollbarMarkers) {
                recreateScrollMarkers();
            }
        };
        getAllMine.onerror = (mineEvent) => {
            console.error("Meguca Extended: Error accessing 'mine' object store:", mineEvent);
        };
    }

    function addMyStyle(newID, newStyle) {
        let myStyle = document.createElement("style");
        //myStyle.type = 'text/css';
        myStyle.id = newID;
        myStyle.textContent = newStyle;
        document.querySelector("head").appendChild(myStyle);
    }

    function findPos(obj) {
        const rect = obj.getBoundingClientRect();
        return {
            left: rect.left + window.scrollX,
            top: rect.top + window.scrollY,
        };
    }

    function createMarker(element, container, isReply) {
        const pageHeight = document.body.scrollHeight;
        const offsetTop = element.offsetTop;
        const percent = offsetTop / pageHeight;

        const marker = document.createElement("div");
        marker.classList.add("marker");
        if (isReply) {
            marker.classList.add("alt");
        }
        marker.style.top = `${percent * 100}vh`;

        marker.addEventListener("click", () => {
            let elem = element?.previousElementSibling || element;
            elem.scrollIntoView({ behavior: "smooth", block: "start" });
        });

        container.appendChild(marker);
    }

    function recreateScrollMarkers() {
        let oldContainer = document.querySelector(".marker-container");
        if (oldContainer) {
            oldContainer.remove();
        }
        // Create marker container
        const markerContainer = document.createElement("div");
        if (settings.showScrollbarMarkers) {
            markerContainer.classList.add("marker-container");
            document.body.appendChild(markerContainer);
        }
        yourPosts.forEach((id) => {
            const elem = document.querySelector(`article[id="p${id}"]`);
            if (elem) {
                createMarker(elem, markerContainer, false);
            }
        });
        yourReplies.forEach((id) => {
            const elem = document.querySelector(`article[id="p${id}"]`);
            if (elem) {
                createMarker(elem, markerContainer, true);
            }
        });
    }

    //Observe changes to #hover-overlay to add the styles (hovered posts)
    const observer = new MutationObserver((mutationsList) => {
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach((node) => {
                    if (node.tagName === "ARTICLE") {
                        const postID = node.id.slice(1);
                        if (yourPosts.some((id) => id === postID)) {
                            node.classList.add("postMine");
                        } else if (yourReplies.some((id) => id === postID)) {
                            node.classList.add("postReply");
                        }
                    }
                });
            }
        }
    });
    const hoverDiv = document.querySelector("#hover-overlay");
    observer.observe(hoverDiv, { childList: true, subtree: true });

    // Add a second observer for #thread-container (new posts)
    const threadObserver = new MutationObserver((mutationsList) => {
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach((node) => {
                    if (node.tagName === "ARTICLE") {
                        checkPost(node);
                        //Recreat markers because the page grew taller. Is this heavy? probably not.
                        if (settings.showScrollbarMarkers) {
                            recreateScrollMarkers();
                        }
                    }
                });
            }
        }
    });

    function checkPost(node) {
        const youIndicator = node.querySelector("header i");
        if (youIndicator && youIndicator.textContent.match(/\(you\)/i)) {
            node.classList.add("postMine");
            const postID = node.id.slice(1);
            yourPosts.push(postID);
        }
        const postLink = node.querySelector(".post-container a.post-link");
        if (postLink && postLink.textContent.match(/\(you\)/i)) { //Can also match the ids from yourPosts
            node.classList.add("postReply");
            const postID = node.id.slice(1);
            yourReplies.push(postID);
        }
        if (node.classList.contains("editing")) {
            //Recheck until each post finishes editing (slowly)
            const editpost = node; //this is necessary
            let checkInterval = setInterval(() => {
                if (!editpost.classList.contains("editing")) {
                    clearInterval(checkInterval);
                    //wait for the post to settle down (waiting for the links to be created)
                    setTimeout(() => {
                        checkPost(editpost);
                        if (settings.showScrollbarMarkers) {
                            recreateScrollMarkers();
                        }
                    }, 1200);
                }
            }, 100);
        }
    }
    const threadContainer = document.querySelector("#thread-container");
    if (threadContainer) {
        threadObserver.observe(threadContainer, { childList: true });
    }
})();