LynxChan Extended Minus Minus

LynxChan Extended with even more features

当前为 2025-05-12 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         LynxChan Extended Minus Minus
// @namespace    https://rentry.org/8chanMinusMinus
// @version      2.3.2
// @description  LynxChan Extended with even more features
// @author       SaddestPanda & Dandelion & /gfg/
// @license      UNLICENSE
// @match        *://8chan.moe/*/res/*
// @match        *://8chan.se/*/res/*
// @match        *://8chan.cc/*/res/*
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.deleteValue
// @grant        GM.registerMenuCommand
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

//TODO LATER MAYBE: combine all CSS into one <style> and use classes on html or body instead.

(async function () {
    "use strict";

    let windowAccessible = false; //Are window functions such as api and posting accessible?
    const SETTINGS_DEFINITIONS = {
        firstRun:{
            default:true,
            hidden:true,
            desc:"You shouldn't be able to see this setting! (firstRun)"
        },
        addKeyboardHandlers:{
            default:true,
            desc:"Add keyboard Ctrl+ hotkeys to the quick reply box (Disable this for 8chanSS compatibility)"
        },
        showScrollbarMarkers:{
            default:true,
            type:"checkbox_with_colors",
            desc:"Show your posts and replies on the scrollbar",
            color1Default:"#0092ff",
            color1Desc:"<b>Your marker:</b>",
            color2Default:"#a8d8f8",
            color2Desc:"<b>Reply marker:</b>"
        },
        spoilerImageType:{
            default:"off",
            desc:"Override how the spoiler thumbnail looks:",
            type:"radio",
            options:{
                off:"Don't change the thumbnail.",
                reveal:"Reveal spoilers <span class='altText lineBefore'>(Previously spoilered images will have a red border around them indicating that they're spoilers.)</span>",
                reveal_blur:"Change to a blurred thumbnail <span class='altText lineBefore'>(Unblurred when you hover your mouse over.)</span>",
                kachina:"Makes the spoiler image Kachina from Genshin Impact.",
                thread:`<b>Use <b style="color: var(--link-color);">"ThreadSpoiler.jpg"</b> from the current thread <span class="altText lineBefore">(first posted jpg, png or webp image with that filename)</span></b>`,
                threadAlt:`same as above with the filename <b style="color: var(--link-color);">"ThreadSpoilerAlt.jpg"</b> <span class="altText lineBefore">(jpg, png or webp; uses ThreadSpoiler.jpg until this is found)</span>`,
                //test:`[TEST OPTION] Set custom spoiler thumb per-thread (For /gacha/ only!)`
            },
            nonewline:true
        },
        overrideBoardSpoilerImage: {
            default:true,
            parent:"spoilerImageType",
            //Not implemented yet
            //depends: function() {return settings.spoilerImageType != "off"},
            desc:"Also override board's custom thumbnail image <span class='altText lineBefore'>(for example, /v/'s spoiler thumbnail is an image of a monitor with a ? inside it)</span>"
        },
        revealSpoilerText:{
            default:"off",
            desc:"Reveal the spoiler text. Or make it into madoka runes.",
            type:"radio",
            options:{
                off:"Don't reveal spoilers.",
                on:"Spoilers will be always be shown by turning the text white.",
                madoka:`Spoilers will turn into madoka runes. Please install <a href="https://www.dropbox.com/s/n6ys414nviitr9y/MadokaRunes-2.0.ttf"><u>MadokaRunes.ttf</u></a> for it to show up properly.`
            }
        },
        markPostEdge:{
            default:true,
            type:"checkbox_with_colors",
            desc:"<span class='boldText'>Style:</span> Mark your posts and replies <span class='altText'>(with a left border)</span>",
            color1Default:"#4BB2FF",
            color1Desc:"<b>Your border:</b>",
            color2Default:"#0066ff",
            color2Desc:"<b>Reply border:</b>",
            nonewline:true
        },
        markYouText:{
            default:true,
            type:"checkbox_with_colors",
            desc:"<span class='boldText'>Style:</span> Color your name and (You) links",
            color1Default:"#ff2222",
            color1Desc:"<b>Color:</b>",
            nonewline:true
        },
        compactPosts:{
            default:true,
            desc:"<span class='boldText'>Style:</span> Make thumbnails and posts more compact",
            nonewline:true
        },
        showStubs:{
            default:true,
            desc:"<span class='boldText'>Style:</span> Show post stubs when filtering",
            nonewline:true
        },
        //I swear this used to be a built in option on 8chan
        halfchanGreentexts:{
            default:false,
            desc:"<span class='boldText'>Style:</span> Make the greentext brighter like 4chan"
        },
        glowFirstPostByID:{
            default:true,
            type:"checkbox_with_colors",
            desc:"Mark new/unique posters by adding a glow effect to their ID",
            color1Default:"#26bf47",
            color1Desc:"<b>Glow color:</b>"
        },
        showPostIndex:{
            default:true,
            type:"checkbox_with_colors",
            desc:"Show the current index of a post on the thread. <span class='altText'>(OP: 1, first post: 2 etc.)</span>",
            color1Default:"#7b3bcc",
            color1Desc:"<b>Index color:</b>"
        },
        preserveQuickReply:{
            default:false,
            desc:"Preserve the quick reply text when closing the box or refreshing the page"
        },
        /*preserveName:{
            default:false,
            desc:"Preserve the last used name when refreshing the page"
        },*/
        reverseSearchOptions:{
            default:{
                pixiv:true,
                booru:true,
                saucenao:true
            },
            desc:"Reverse image search buttons to show:",
            type:"checkbox_multiple_dict", //Maybe "multiple_array" or "bitfield" types allowed in the future
            options:{
                pixiv:"Pixiv <span class='altText lineBefore'>Shown if the filename matches an image downloaded from Pixiv</span>",
                booru:"Gelbooru / Danbooru / Safebooru <span class='altText lineBefore'>Shown if the filename contains an md5 hash</span>",
                saucenao:"Saucenao <span class='altText lineBefore'>Always shown, uses JS to download and reupload the image to saucenao</span>"
            },
            nonewline:true
        },
        reverseSearchBooruSite:{
            desc:"Booru to link if the above option is enabled",
            type:"dropdown",
            default:"gelbooru",
            choices:{
                "gelbooru":"https://gelbooru.com/index.php?page=post&s=list&tags=md5%3a",
                "danbooru":"https://danbooru.donmai.us/posts?tags=md5%3a",
                "safebooru":"https://safebooru.org/index.php?page=post&s=list&tags=md5%3a"
            }
        }
        /*redirectToCatalog:{
            default:false,
            desc:"Redirect to catalog when clicking on the index."
        }*/
    }

    const settingsNames = Object.keys(SETTINGS_DEFINITIONS);

    //Collect all color fields for checkbox_with_colors settings
    //In the userscript storage they look like settingName_color1 etc.
    const colorSettingKeys = [];
    settingsNames.forEach(key => {
        const def = SETTINGS_DEFINITIONS[key];
        if (def.type === "checkbox_with_colors") {
            Object.keys(def).forEach(k => {
                const match = k.match(/^color(\d+)Default$/);
                if (match) {
                    colorSettingKeys.push(`${key}_color${match[1]}`);
                }
            });
        }
    });

    //Compose all keys to load: main settings + color fields
    const allSettingKeys = [...settingsNames, ...colorSettingKeys];

    //For each color field, get its default from the definition
    function getDefaultForKey(key) {
        const colorMatch = key.match(/^(.+)_color(\d+)$/);
        if (colorMatch) {
            const [_, base, idx] = colorMatch;
            const def = SETTINGS_DEFINITIONS[base];
            //Return color setting default like color1Default
            return def && def[`color${idx}Default`] ? def[`color${idx}Default`] : undefined;
        }
        //Return regular setting
        return SETTINGS_DEFINITIONS[key]?.default;
    }

    const allSettingDefaults = allSettingKeys.map(getDefaultForKey);
    const allSettingValues = await Promise.all(allSettingKeys.map((key, i) => GM.getValue(key, allSettingDefaults[i])));
    const settings = Object.fromEntries(allSettingKeys.map((key, i) => [key, allSettingValues[i]]));

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

    function waitForDom(callback) {
        if (document.readyState === "loading") {
            //Loading hasn't finished yet. Wait for the inital document to load and start.
            document.addEventListener("DOMContentLoaded", callback);
        } else {
            //Document has already loaded. Start.
            callback();
        }
    }

    if (document?.head) {
        runASAP();
    } else {
        //On some environments document.head doesn't exist yet?
        waitForDom(runASAP);
    }

    async function runASAP() {
        // Migrations can be removed in a few weeks 
        // Migrations are disabled now. Keeping the code for potential future migrations

        // // Migrate old useExtraStylingFixes setting if present
        // const oldStyling = await GM.getValue("useExtraStylingFixes", undefined);
        // if (typeof oldStyling !== "undefined") {
        //     // If oldStyling is false, set both new options to false
        //     if (oldStyling === false) {
        //         settings.markPostEdge = false;
        //         settings.compactPosts = false;
        //         await GM.setValue("markPostEdge", false);
        //         await GM.setValue("compactPosts", false);
        //     }
        //     // Remove the old setting
        //     await GM.deleteValue("useExtraStylingFixes");
        // }

        //Secret tip for anyone manually editing colors:
        //if you edit the saved value in your userscript manager's settings database manually, you can use semi-transparent colors for the color pickers (until you click save on the settings menu).
        //or easier: just copy the relevant part of the css and paste it to the css box in the website settings. Add !important if you want to force it like: color: red !important;

        //Apply all the styles as soon as possible
        if (settings.compactPosts) {
            addMyStyle("lynx-compact-posts", `
                /* smaller thumbnails & image paddings */
                body .uploadCell img:not(.imgExpanded) {
                    max-width: 160px;
                    max-height: 125px;
                    object-fit: contain;
                    height: auto;
                    width: auto;
                    margin-right: 0em;
                    margin-bottom: 0em;
                }

                .imgExpanded { max-height:100vh; object-fit:contain }

                .uploadCell .imgLink {
                    margin-right: 1.5em;
                }

                /* smaller post spacing (not too much) */
                .divMessage {
                    margin: .8em .8em .5em 3em;
                }
            `);
        }

        const markerColor1 = settings.showScrollbarMarkers_color1 || SETTINGS_DEFINITIONS.showScrollbarMarkers.color1Default;
        const markerColor2 = settings.showScrollbarMarkers_color2 || SETTINGS_DEFINITIONS.showScrollbarMarkers.color2Default;
        const indexColor = settings.showPostIndex_color1 || SETTINGS_DEFINITIONS.showPostIndex.color1Default;
        const glowColor = settings.glowFirstPostByID_color1 || SETTINGS_DEFINITIONS.glowFirstPostByID.color1Default;
        addMyStyle("lynx-extended-css", `
        :root {
            --showScrollbarMarkers_color1: ${markerColor1};
            --showScrollbarMarkers_color2: ${markerColor2};
            --showPostIndex_color1: ${indexColor};
            --glowFirstPostByID_color1: ${glowColor};
        }

        /* Booru links */
        /* For multiple uploads the button is below the image, for single upload it's next to the filename */
        /* single upload buttons can also be moved below the image by setting the innerPost as relative. */
        .lynxReverseImageSearch a:hover {
            text-decoration: underline;
        }
        .lynxReverseImageSearch > a {
            margin: 0 4px 0 1px;

            &.fetch-awaiting:after {
                content: attr(data-booruname)" (please wait...)" !important;
            }

            &.fetch-failed:after {
                content: attr(data-booruname)" (failed!)" !important;
            }
        }

        .panelUploads:not(.multipleUploads) .lynxReverseImageSearch > a:after, lynxReverseImageSearch.showSearchNames > a:after {
            content: attr(data-booruname);
        }

        .multipleUploads .uploadCell:has(.lynxReverseImageSearch) {
            position: relative;
        }

        .multipleUploads .lynxReverseImageSearch {
            position: absolute;
            bottom: 0;
            left: 0;
            z-index: 2; /* Fixes the button not being clickable if you have spoiler thumbs disabled */

            & a {
                font-size: 0.9em;
                text-shadow: 0px 0px 1px var(--contrast-color);
                filter: drop-shadow(0px 0px 1px black) drop-shadow(0px 0px 1px var(--contrast-color));
                &:hover,
                &:active {
                    background: var(--contrast-color);
                    opacity: 0.9;
                    max-width: 999px;
                    &:after {
                        content: attr(data-booruname);
                    }
                }
            }
        }


        /* Scrollbar you and reply markers */
        .marker-container {
            position: fixed;
            top: 16px;
            right: 0;
            width: 10px;
            height: calc(100vh - 40px);
            z-index: 11000;
            pointer-events: none;
        }

        .marker {
            position: absolute;
            width: 100%;
            height: 6px;
            background: var(--showScrollbarMarkers_color1);
            cursor: pointer;
            pointer-events: auto;
            border-radius: 40% 0 0 40%;
            z-index: 5;
            filter: drop-shadow(0px 0px 1px #000000BA);
        }

        .marker.alt {
            background: var(--showScrollbarMarkers_color2);
            z-index: 2;
        }

        .postNum.index {
            color: var(--showPostIndex_color1);
            font-weight: bold;
        }

        .labelId.glows {
            box-shadow: 0 0 15px var(--glowFirstPostByID_color1);
        }

        #lynxExtendedMenu {
            position: fixed;
            top: 15px;
            left: 50%;
            transform: TranslateX(-50%);
            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: var(--contrast-color);
            color: var(--text-color);
            border: 1px solid #737373;
            border-radius: 4px;
            max-height:90%;
            overflow-y: auto;

            & .altText {
                opacity: 0.8;
                font-size: 0.9em;

                &.lineBefore:before {
                    content: "—— ";
                }
            }

            & .boldText {
                color: var(--link-color);
                font-weight: bold;
            }

            & .colorLabel {
                padding: 3px 2px 3px 4px;
                border-radius: 8px;
                box-shadow: 0px 0px 1px currentColor;
                margin-left: 0.5em;
            }

            & input[type="color"] {
                width: 40px;
                height: 20px;
                padding: 1px;
                transform: translate(0, 2px);
            }

            & button {
                padding: 10px 20px;
                margin-right: 4px;
                margin-bottom: 0;
                filter: contrast(115%) brightness(110%);
                &:hover {
                    filter: brightness(130%);
                }
            }
        }
        #lynxExtendedMenu > .settings-footer {
            height: auto;
        }
        @media screen and (max-width: 1000px) {
            #lynxExtendedMenu{
                right:0;
                width:90%;
                /*bottom:15px;*/
            }
        }

        .lynxExtendedButton::before {
            content: "\\e0da";
        `);

        if (settings.markPostEdge) {
            const color1 = settings.markPostEdge_color1 || SETTINGS_DEFINITIONS.markPostEdge.color1Default;
            const color2 = settings.markPostEdge_color2 || SETTINGS_DEFINITIONS.markPostEdge.color2Default;
            addMyStyle("lynx-mark-posts", `
                /* 
                README:
                Mark your posts and replies with a left border. Specificity order: (you) > (reply). 
                Important: The :not(#SP1) selectors are used for extra specificity.
                These are made extra specific so we can override ones from other userscripts.
                (because Lynx-- has an option to disable only this and also has the ability to customize the color)
                */
                /* Match your posts. This is easy. */
                body:not(#SP1#SP1) .innerPost.yourPost {
                    border-left: 3px dashed var(--markPostEdge_color1, ${color1});
                }

                /* 
                * Match replies:
                * This can be a simple .divMessage > .quoteLink 
                * or it can be a .divMessage > details > .spoiler > s > u > .quoteLink (or something like that)
                */
                body:not(#SP1) .innerPost.replyPost {
                    border-left: 2px solid var(--markPostEdge_color2, ${color2});
                }
            `);
        }

        if (settings.markYouText) {
            const color1 = settings.markYouText_color1 || SETTINGS_DEFINITIONS.markYouText.color1Default;
            addMyStyle("lynx-mark-you-text", `
                    .youName { color: var(--markYouText_color1, ${color1}); }
                    .you { --link-color: var(--markYouText_color1, ${color1}); }
            `);
        }

        if (settings.halfchanGreentexts) {
            addMyStyle("lynx-halfchanGreentexts",
                `.greenText {
                    filter: brightness(110%);
                }
            `);
        }

        if (settings.showStubs === false) {
            addMyStyle("lynx-hide-stubs",`
            .postCell:has(> span.unhideButton.glowOnHover) {
                display: none;
            }
            `);
        }

        if (settings.revealSpoilerText=="on") {
            addMyStyle("lynx-reveal-spoilertext1",`
                span.spoiler { color: white }
            `);
        } else if (settings.revealSpoilerText=="madoka") {
            addMyStyle("lynx-reveal-spoilertext2",`
                span.spoiler:not(:hover) {
                    color: white;
                    font-family: MadokaRunes !important;
                }
            `);
        }

    } //End of runASAP()

    //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
    //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
    //Everything in runAfterDom runs after document has loaded (like @run-at document-end)
    async function runAfterDom() {
        console.log("%cLynx Minus Minus Started with settings:", "color:rgb(0, 140, 255)", settings);

        //Get the following window objects
        //unsafeWindow works on chrome and Tampermonkey FF, wrappedJSObject works on Firefox VM.
        //TODO when the site is updated these and unsafewindow can probably be removed.
        const window_api = api || window?.api || unsafeWindow?.api || wrappedJSObject?.api || undefined;
        const window_posting = posting || window?.posting || unsafeWindow?.posting || wrappedJSObject?.posting || undefined;
        const window_qr = qr || window?.qr || unsafeWindow?.qr || wrappedJSObject?.qr || undefined;
        if (window_api && window_posting && window_qr) {
            windowAccessible = true;
        } else {
            //I think greasemonkey sandboxes the script. I use violentmonkey though
            console.error("Lynx Minus Minus: This JS script is sandboxed and can't access page JS... (If you can read this, let me know what browser/extension does this. Or maybe the site just failed to load?)")
        }

        if (windowAccessible) {
            console.log("The script is not sandboxed. Adding quick reply (Ctrl+Q and Alt+R) and thread watcher (Alt+T) hotkeys.")
            function quickReplyShortcut(ev) {
                if ((ev.ctrlKey && ev.key == "q") || (ev.altKey && ev.key=="r")) {
                    ev.preventDefault();
                    //8chan's HTML will keep the text after a reload so attempt to clear it again
                    if (settings.preserveQuickReply===false) {
                        document.getElementById("qrbody").value = "";
                    }
                    window_qr.showQr(); document.getElementById('qrbody')?.focus();
                };
                // Alt+T for thread watcher
                if (ev.altKey && (ev.key === "t" || ev.key === "T")) {
                    ev.preventDefault();
                    const watcherBtn = document.querySelector("body > nav a.watcherButton");
                    if (watcherBtn) watcherBtn.click();
                }
            }
            document.addEventListener("keydown",quickReplyShortcut);
        }

        function createSettingsButton() {
            //Desktop
            document.querySelector("#navLinkSpan > .settingsButton").insertAdjacentHTML("afterend", `
            <span>/</span>
            <a id="navigation-lynxextended" class="coloredIcon lynxExtendedButton" title="LynxChan Extended-- Settings"></a>
            `);
            //Mobile
            document.querySelector("#sidebar-menu > ul > li > .settingsButton").parentElement.insertAdjacentHTML("afterend", `
                <li>
                    <a id="navigation-lynxextended-mobile" class="coloredIcon lynxExtendedButton" title="LynxChan Extended-- Settings">Lynx Ex-- Settings</a>
                </li>
            `);
            document.querySelector("#navigation-lynxextended").addEventListener("click", openMenu);
            document.querySelector("#navigation-lynxextended-mobile").addEventListener("click", openMenu);
        }

        //Register menu command for the settings button
        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();
        }

        function replyKeyboardShortcuts(ev) {
            if (ev.ctrlKey) {
                let combinations = {
                    "s":["[spoiler]","[/spoiler]"],
                    "b":["'''","'''"],
                    "u":["__","__"],
                    "i":["''","''"],
                    "d":["[doom]","[/doom]"],
                    "m":["[moe]","[/moe]"]
                }
                for (var key in combinations)
                {
                    if (ev.key == key)
                    {
                        ev.preventDefault();
                        console.log("ctrl+"+key+" pressed in textbox")
                        const textBox = ev.target;
                        let newText = textBox.value;
                        const tags = combinations[key]
                        const selectionStart = textBox.selectionStart
                        const selectionEnd = textBox.selectionEnd
                        
                        if (selectionStart == selectionEnd) { //If there is nothing selected, make empty tags and center the cursor between it
                            document.execCommand("insertText",false, tags[0] + tags[1]);
                            //Center the cursor between tags
                            textBox.selectionStart = textBox.selectionEnd = (textBox.selectionEnd - tags[1].length);
                        } else {
                            //Insert text and keep undo/redo support (Only replaces highlighted text)
                            document.execCommand("insertText",false, tags[0] + newText.slice(selectionStart, selectionEnd) + tags[1])
                        }
                        return;
                    }
                }
                //Ctrl+Enter to send reply
                if (ev.key=="Enter") {
                    document.getElementById("qrbutton")?.click()
                }
            }
        }

        if (settings.addKeyboardHandlers) {
            document.getElementById("qrbody").addEventListener("keydown", replyKeyboardShortcuts);
            document.getElementById("quick-reply").addEventListener('keydown',function(ev) {
                if (ev.key == "Escape") {
                    document.getElementById("quick-reply").querySelector(".close-btn").click()
                }
            })
        }

        //I'm not sure who would ever want this on but I'm making it an option anyways
        if (settings.preserveQuickReply===false) {
            document.getElementById("quick-reply").querySelector(".close-btn").addEventListener("click", function(ev){
                document.getElementById("qrbody").value = "";
            });
            //This doesn't replace the built in onclick but adds to it so the original onclick will still bring up the qr
            document.getElementById("replyButton")?.addEventListener("click", function(ev){
                ev.preventDefault();
                const qrBody = document.getElementById("qrbody");
                if (qrBody) {
                    qrBody.value = "";
                    qrBody?.focus();
                }
            });
        }

        function openMenu() {
            const oldMenu = document.getElementById("lynxExtendedMenu");
            if (oldMenu) {
                oldMenu.remove();
                return;
            }
            // Create options menu
            const menu = document.createElement("div");
            menu.id = "lynxExtendedMenu";
            menu.innerHTML = `
            <h3 style="text-align: center; color: var(--subject-color);" class='settings-header'>LynxChan Extended-- Options</h3>
            <p style="text-align: center;">Version ${GM.info.script.version}</p><br>
            `;

            
            //we use createElement() here instead of setting innerHTML so we can attach onclick to elements
            //...In the future, at least. There aren't any onclicks added yet.
            let settings_content = document.createElement("div");
            settings_content.classList.add("settings-content");
            Object.keys(SETTINGS_DEFINITIONS).forEach((name) => {
                const setting = SETTINGS_DEFINITIONS[name];
                if (setting.hidden) {
                    //pass
                }
                else if (setting.type == "radio") {
                    let html = `<span>${setting.desc}</span><br><form id="${name}" action='#'>`;
                    for (const [value, description] of Object.entries(setting.options)) {
                        html += `
                        <label>
                            <input name="${name}" type="radio" value="${value}" ${settings[name]==value ? "checked" : ""}
                            <span>${description}</span>
                        </label><br>
                        `;
                    }
                    html += `</form>${setting.nonewline ? '' : '<br>'}`;
                    settings_content.innerHTML += html;
                } else if (setting.type == "checkbox_multiple_dict") {
                    const dict = settings[name]
                    let html = `<span>${setting.desc}</span><br><form id="${name}" action='#'>`;
                    for (const [key, description] of Object.entries(setting.options)) {
                        html += `
                        <label>
                            <input name="${name}" type="checkbox" value="${key}" ${dict[key] ? "checked" : ""}
                            <span>${description}</span>
                        </label><br>
                        `;
                    }
                    html += `</form>${setting.nonewline ? '' : '<br>'}`;
                    settings_content.innerHTML += html;
                } else if (setting.type == "dropdown") {
                    let html = `<label for="${name}">${setting.desc}:</label><select id="${name}">`
                    Object.keys(setting['choices']).forEach(value => {
                        html+=`<option value="${value}" ${settings[name]==value ? "selected" : ""}>${value}</option>`
                    })
                    html+=`</select><br>${setting.nonewline ? '' : '<br>'}`;
                    settings_content.innerHTML += html;

                } else if (setting.type == "checkbox_with_colors") {
                    let colorHtml = "";
                    let colorFields = Object.keys(setting).filter(k => /^color\d+Default$/.test(k));
                    colorFields.forEach((colorKey) => {
                        const idx = colorKey.match(/^color(\d+)Default$/)[1];
                        const colorValue = settings[`${name}_color${idx}`] || setting[`color${idx}Default`];
                        const colorDesc = setting[`color${idx}Desc`] || "";
                        colorHtml += `
                        <label class="colorLabel">
                            ${colorDesc}
                            <input type="color" id="${name}_color${idx}" value="${colorValue}" ${settings[name] ? '' : 'disabled'}>
                        </label>
                        `;
                    });
                    settings_content.innerHTML += `
                    <label>
                        <input type="checkbox" id="${name}" ${settings[name] ? "checked" : ""}>
                        ${setting.desc}
                    </label>
                    ${colorHtml}
                    <br>${setting.nonewline ? '' : '<br>'}`;
                } else {
                    settings_content.innerHTML += `
                    <label>
                        <input type="checkbox" id="${name}" ${settings[name] ? "checked" : ""}>
                        ${setting.desc}
                    </label><br>${setting.nonewline ? '' : '<br>'}`;
                }
            })
            menu.appendChild(settings_content);
            menu.innerHTML += `
                <div class='settings-footer'>
                    <button id="saveSettings">Save</button>
                    <button id="closeMenu">Close</button>
                    <button id="resetSettings" style="float: right;">Reset</button>
                </div>
            `;
            document.body.appendChild(menu);

            // Save button functionality
            document.getElementById("saveSettings").addEventListener("click", async () => {
                Object.keys(SETTINGS_DEFINITIONS).forEach((name) => {
                    const setting = SETTINGS_DEFINITIONS[name];
                    if (!('hidden' in setting)) {
                        if (setting.type=="radio") {
                            settings[name] = menu.querySelector(`input[name="${name}"]:checked`).value;
                        } else if (setting.type == "checkbox_multiple_dict") {
                            
                            const d = {}
                            menu.querySelectorAll(`input[name="${name}"]`).forEach(checkbox => {
                                d[checkbox.value] = checkbox.checked;
                            })
                            settings[name] = d;
                            
                        } else if (setting.type=="dropdown") {
                            settings[name] = document.getElementById(name).value;
                        } else if (setting.type=="checkbox_with_colors") {
                            settings[name] = document.getElementById(name).checked;
                            let colorFields = Object.keys(setting).filter(k => /^color\d+Default$/.test(k));
                            colorFields.forEach((colorKey) => {
                                const idx = colorKey.match(/^color(\d+)Default$/)[1];
                                const colorName = `${name}_color${idx}`;
                                const colorValue = document.getElementById(colorName).value;
                                settings[colorName] = colorValue;
                                // Set CSS variable on body (so it can be used without a refresh)
                                document.body.style.setProperty(`--${colorName}`, colorValue);
                            });
                        } else {
                            settings[name] = document.getElementById(name).checked;
                        }
                    }
                })
                console.log("Saving settings ",settings)
                await Promise.all(Object.entries(settings).map(([key, value]) => GM.setValue(key, value)));
                setTimeout(()=>{
                    alert("Settings saved!\nFor most settings you must refresh the page for the changes to take effect.\n\n(only color pickers don't need a refresh)");
                }, 1);
                // menu.remove();
            });

            // Reset button functionality
            document.getElementById("resetSettings").addEventListener("click", async () => {
                if (!confirm("Are you sure you want to reset all settings? This will delete all saved data.")) return;
                const keys = await GM.listValues();
                await Promise.all(keys.map(key => GM.deleteValue(key)));
                alert("All settings have been reset.\nRefreshing automatically for the changes to take effect.");
                menu.remove();
                location.reload();
            });

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

        }

        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}%`;
            marker.dataset.postid = element.id;

            marker.addEventListener("click", () => {
                let elem = element?.previousElementSibling || element;
                if (elem) 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");
            markerContainer.classList.add("marker-container");
            document.body.appendChild(markerContainer);
    
            // Match and create markers for "my posts" (matches native & dollchan)
            document.querySelectorAll(".postCell:has(> .innerPost > .postInfo.title :is(.linkName.youName, .de-post-counter-you)),.postCell:has(.innerPost.de-mypost)")
                .forEach((elem) => {
                    createMarker(elem, markerContainer, false);
                });
    
            // Match and create markers for "replies" (matches native & dollchan)
            document.querySelectorAll(".postCell:has(> .innerPost > .divMessage :is(.quoteLink.you, .de-ref-you)),.postCell:has(.innerPost.de-mypost-reply)")
                .forEach((elem) => {
                    createMarker(elem, markerContainer, true);
                });
        }
    
        let postCount = 1;
        const postIndexLookup = {};
        function addPostCount(post, newpost = true) {
            // const posts = Array.from(document.querySelectorAll(".innerOP, .divPosts > .postCell"));
            if (post.querySelector(".postNum")) {
                return;
            }
    
            const postInfoDiv = post.getElementsByClassName("title")[0]
            if (!postInfoDiv) {
                console.error("[Lynx--] Failed to find post for div ", post);
                return;
            }
    
            const posterNameDiv = postInfoDiv.getElementsByClassName("linkName")[0];
            const postNumber = post.querySelector(".linkQuote")?.textContent;
            if (!postNumber) return;

            let localCount = postCount;
            if (newpost) {
                postIndexLookup[postNumber] = localCount;
                postCount++;
            } else {
                //Show cached post count for inlines & hovers
                localCount = postIndexLookup[postNumber];
                if (!localCount) return;
            }

            let newNode = document.createElement("span");
            newNode.innerText = localCount;
            newNode.className = "postNum index";
            if (localCount < Infinity) //knownBumpLimit
            {
                // color is handled by .postNum.index
                newNode.style = "";
            }
            else
            {
                newNode.style = "color: rgb(255, 4, 4); font-weight: bold;"
            }
            postInfoDiv.insertBefore(newNode, posterNameDiv);
            let foo = document.createTextNode("\u00A0"); // Non-breaking space
            postInfoDiv.insertBefore(foo, posterNameDiv);
        }
    
        //mark cross-thread links.
        const indicateCrossLinks = function(post) {
            if (!windowAccessible) return; //Return if the window is not accessible
            const crossLinks = post.querySelectorAll(`a.quoteLink:not(.crossThread):not([href*='${window_api.boardUri}/res/${window_api.threadId}'])`);
            crossLinks.forEach(crossLink => {
                //ignore cross-board links (they look obvious like >>>/board/123456 )
                if (!crossLink.href.includes(`/${window_api.boardUri}/`)) {
                    return;
                }
                crossLink.classList.add("crossThread");
                const hrefTokens = crossLink.href.split("#");
                const quoteLinkId = hrefTokens[1];
                crossLink.innerHTML = ">>" + quoteLinkId;
            });
        }

        function addDeletedChecks(post) {
            if (!windowAccessible) return; //Return if the window is not accessible
            const postLinks = post.querySelectorAll(`a.quoteLink[href*='${window_api.boardUri}/res/${window_api.threadId}']`);
            //This goes bottom to top so we stop when we've reached a post with a check attached
            for (let i = postLinks.length-1; i>=0; i--)
            {
                //We've reached posts where we already added numbers, 
                // there's no need to keep going.
                if (postLinks[i].hasMouseOverEvent) {
                    break;
                }
                var evListener = function(ev) {
                    if (!document.getElementById(ev.target.href.split("#").pop())) {
                        ev.target.classList.add("deleted")
                        //Sadly this doesn't actually work and I don't know why (S.Panda: postlinks[i] is gone by the time the event is ran)
                        //postLinks[i].removeEventListener("mouseenter",evListener)
                        ev.target.closest("a.quoteLink")?.removeEventListener("mouseenter", evListener);
                    }
                }
                postLinks[i].addEventListener("mouseenter", evListener);
                //Why does js allow this
                postLinks[i].hasMouseOverEvent = true;
            }
        }

        addMyStyle("lynx-linkHelpers",`
            .quoteLink.crossThread::after {
                content: " \(Cross-thread\)";
            }
            .quoteLink.deleted::after {
                content: " \(Deleted\)";
            }
        `)


        if (settings.reverseSearchOptions.saucenao) {
            //have to shove this at the bottom of the document since the entire thread is inside a form div and I can't nest it
            const formm =`
            <form target="_blank" action="https://saucenao.com/search.php" method="POST" enctype="multipart/form-data" style="display:none">
            <input type="file" name="file" size="50" id='saucenao_file_input'>
            <input type="submit" accesskey="s" value="get sauce" id='saucenao_submit'>
            </form>`
            document.body.insertAdjacentHTML('beforeend', formm);
        }

        function imageSearchHooks(post) {
            //TODO MAYBE: Make this button open a small menu (like the post menu) instead of multiple buttons. AI can do it in 20 seconds.
            // .some() will return true if any value in the dict is true
            // So inverting this means no values are true, so return without doing anything
            if (!Object.values(settings.reverseSearchOptions).some(Boolean)) {
                return;
            }

            const fileNameElements = Array.from(post.querySelectorAll(".originalNameLink[href]"));
            const regex_md5sum = /[0-9a-f]{32}/g;
            const regex_pixiv = /(\d+)_p\d+/;

            for (let i = fileNameElements.length-1; i>=0; i--)
            {
                const parent = fileNameElements[i].parentElement
                if (parent.querySelector(".lynxReverseImageSearch")) {
                    return;
                }
                const attachmentFileName = fileNameElements[i].download;

                const span = document.createElement("span");
                span.classList.add("lynxReverseImageSearch");

                let searchButtonsAdded = 0

                let m;
                if (settings.reverseSearchOptions.pixiv && (m = regex_pixiv.exec(attachmentFileName)) !== null) {
                    span.innerHTML += `<a rel="noopener noreferrer" target="_blank" data-booruname="pixiv" href="https://pixiv.net/i/${m[1]}">🔍︎</a>`
                    searchButtonsAdded++;
                }
                //This is 'else if' because these options are mutually exclusive - a filename will never match pixiv AND an md5 hash
                //Careful with this insane abuse of conditionals, the order of operations matters (&& is before = without parenthesis)
                //And we don't want to match >1 because that could be an 8chan hash (There should only be 1 md5 hash in a file name anyways)
                else if (settings.reverseSearchOptions.booru && (m = [...attachmentFileName.matchAll(regex_md5sum)]) && m?.length == 1) {
                    span.innerHTML += `<a rel="noopener noreferrer" target="_blank" data-booruname="${settings.reverseSearchBooruSite}" href="${SETTINGS_DEFINITIONS['reverseSearchBooruSite']['choices'][settings.reverseSearchBooruSite]}${m[0]}">🔍︎</a>`
                    searchButtonsAdded++;
                }

                if (settings.reverseSearchOptions.saucenao) {
                    //Logic: for everything thats not supported i.e. not an image use the thumbnail if its available
                    //Supported image extensions for Saucenao direct search
                    const imageExts = /\.(png|jpe?g|webp|bmp|gif|avif|jxl)$/i;
                    let validImage = true;
                    let useThumbInstead = false;
                    let thumbUrl = null;
                    if (!imageExts.test(attachmentFileName)) {
                        // Not a supported image, try to find a thumbnail
                        const uploadCell = parent.closest('.uploadCell');
                        if (uploadCell) {
                            const thumbImg = uploadCell.querySelector('.imgLink > img');
                            const thumbSrc = thumbImg?.getAttribute("src");
                            if (thumbImg && thumbSrc?.startsWith('/.media/t_')) {
                                useThumbInstead = true;
                                thumbUrl = thumbSrc;
                            }
                        }
                        // If no valid thumbnail, don't add saucenao
                        if (!useThumbInstead) {
                            // Don't add saucenao button for this file
                            validImage = false;
                        }
                    }

                    const sauceNaoWrapper = function(ev) {
                        ev.preventDefault(); //Prevent <details> node from contracting
                        const a = ev.currentTarget;
                        a.classList.add("fetch-awaiting");
                        const fetchUrl = useThumbInstead ? thumbUrl : fileNameElements[i].href;
                        const fetchName = useThumbInstead ? ("thumbnail_" + attachmentFileName) : attachmentFileName;
                        fetch(fetchUrl)
                            .then(resp => {
                                if (!resp.ok) throw new Error("Fetch failed");
                                return resp.blob();
                            })
                            .then(blob => {
                                let file = new File([blob], fetchName, {type: blob.type} );
                                let dataTransfer = new DataTransfer();
                                dataTransfer.items.add(file);
                                document.getElementById("saucenao_file_input").files = dataTransfer.files;
                                document.getElementById("saucenao_submit").click();
                                a.classList.remove("fetch-awaiting");
                            })
                            .catch(() => {
                                a.classList.remove("fetch-awaiting");
                                a.classList.add("fetch-failed");
                            });
                    }

                    if (validImage) {
                        const a = document.createElement("a");
                        a.setAttribute("data-booruname","saucenao")
                        a.innerText = '🔍︎'
                        a.addEventListener("click", sauceNaoWrapper)
                        span.appendChild(a);
                        //span.innerHTML += `<a data-booruname='saucenao' onclick=''>🔍︎</a>`
                        searchButtonsAdded++;
                    }
                }

                if (searchButtonsAdded > 1) {
                    span.classList.add("showSearchNames")
                }

                if (searchButtonsAdded > 0) {
                    parent.insertAdjacentElement("beforeend", span);
                }
            }
        }
    
        /*function glowpost() {
            // Create a frequency map to track occurrences of each item
            const list = document.querySelectorAll(".labelId");
            const countMap = Array.from(list).reduce((acc, item) => {
              acc[item.style.backgroundColor] = (acc[item.style.backgroundColor] || 0) + 1;
              return acc;
            }, {});
            
            // Filter the list to keep only items with a count of 1
            Array.from(list).filter(item => countMap[item.style.backgroundColor] === 1).forEach((item) => {
                item.style.boxShadow = "0 0 15px #26bf47";
                item.title = "This is the first post from this ID.";
            });
        }*/
        var idMap = {};
        const glowpost = function(post, newpost = true) {
            const list = post.querySelectorAll(".labelId");
            const postNumber = post.querySelector(".linkQuote")?.textContent;
            list.forEach((poster) => {
                const bgColor = poster.style.backgroundColor;
                if (newpost && idMap[bgColor] === undefined) {
                    idMap[bgColor] = postNumber;
                    poster.classList.add("glows");
                    poster.title = "This is the first post from this ID.";
                } else if (!newpost && idMap[bgColor] == postNumber) {
                    poster.classList.add("glows");
                    poster.title = "This is the first post from this ID.";
                }
            });
        }

        const revealSpoilerImages = function(post) {
            const spoilers = post.querySelectorAll(".imgLink > img:is([src='/spoiler.png'],[src*='/custom.spoiler'])");
            spoilers.forEach(spoiler => {
                spoiler.classList.add('spoiler-thumb');
                const parent = spoiler.parentElement;
                const hrefTokens = parent.href.split("/");
                const fileNameTokens = hrefTokens[4].split(".");
          
                const thumbUrl = `/.media/t_${fileNameTokens[0]}`;
                spoiler.src = thumbUrl;
                //spoiler.style.border = "2px dotted red";
            });
        }

        if (settings.spoilerImageType.startsWith("reveal")) {
            addMyStyle("lynx-reveal-spoilerimage",`
                img.spoiler-thumb {
                    transition: 0.2s;
                      outline: 2px dotted #ff0000ee;
                    ${settings.spoilerImageType=="reveal_blur" ? "filter: blur(10px);" : ""}
                }
                img.spoiler-thumb:hover {
                    filter: blur(0);
                }
            `)
        }
    
        // Add functionality to apply the custom spoiler image CSS
        let threadSpoilerFound = false;
        let tsFallbackUsed = false;
        function setThreadSpoiler(post) {
            if (threadSpoilerFound) return;

            let spoilerImageUrl = null;

            //When the option is "threadAlt", fallback to "thread" if "threadAlt" doesn't exist yet.
            if (settings.spoilerImageType == "threadAlt") {
                const altSpoilerLink = Array.from(post.querySelectorAll('a.originalNameLink[download^="ThreadSpoilerAlt"]')).find(link => /\.(jpg|jpeg|png|webp)$/i.test(link.download));
                spoilerImageUrl = altSpoilerLink ? altSpoilerLink.href : null;
                tsFallbackUsed = false; //stop looking for threadAlt
            }

            if (settings.spoilerImageType == "thread" || (!spoilerImageUrl && !tsFallbackUsed && settings.spoilerImageType == "threadAlt")) {	
                const spoilerLink = Array.from(post.querySelectorAll('a.originalNameLink[download^="ThreadSpoiler"]')).find(link => /\.(jpg|jpeg|png|webp)$/i.test(link.download));
                spoilerImageUrl = spoilerLink ? spoilerLink.href : null;
                if (settings.spoilerImageType == "threadAlt") {
                    tsFallbackUsed = true; //Keep looking for threadAlt
                }
            } else if (settings.spoilerImageType == "test") {
                const myArray = [
                    'https://8chan.moe/.media/f76e9657d6b506115ccd0ade73d3d562777a441e4b6bb396610669396ff3032a.png',
                    'https://8chan.moe/.media/1074fdb6eea4ba609910581e7824106736a1bcad446ace1ae0792b231b52cf9a.png',
                    'https://8chan.moe/.media/c32b4de8490d7e77987f0e2a381d5935ffa6fec9b98c78ea7c05bd4381d6f64b.png',
                    'https://8chan.moe/.media/bb225302110d52494ec2bea68693d566acee09767212ce4ee8c0d83d49cfa05b.png'
                ];
                spoilerImageUrl = myArray[Math.floor(Math.random() * myArray.length)];
                addMyStyle("lynx-thread-spoiler-css1", `
                    body {
                        --spoiler-img: url("${spoilerImageUrl}")
                    }
                    .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]),
                    .uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]) {
                        background-image: var(--spoiler-img);
                        background-size: cover;
                        background-position: center;
                        & > img {
                            opacity: 0;
                        }
                    }
                `);
                threadSpoilerFound = true;
                return;
            }

            if (spoilerImageUrl) {
                document.head?.querySelector("#lynx-thread-spoiler-css2")?.remove(); //Remove if the style already exists (from fallback)
                addMyStyle("lynx-thread-spoiler-css2", `
                    ${settings.overrideBoardSpoilerImage ? '.uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]),' : "" }
                    .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]) {
                        background-image: url("${spoilerImageUrl}");
                        background-size: cover;
                        background-position: center;
                        outline: dashed 2px #ff000090;
                        & > img {
                            opacity: 0;
                        }
                    }
                `);
                if (!tsFallbackUsed) {
                    threadSpoilerFound = true;
                }
            }
        }

        if (settings.spoilerImageType=="kachina") {
            addMyStyle("lynx-kachinaSpoilers",`
                ${settings.overrideBoardSpoilerImage ? '.uploadCell:not(.expandedCell) a.imgLink:has(>img[src*="/custom.spoiler"]),' : "" }
                .uploadCell:not(.expandedCell) a.imgLink:has(>img[src="/spoiler.png"]) {
                    background-size: cover;
                    background-position: center;
                    margin-right:5px;
                    background-image: url("data:image/jpeg;base64,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");
                    & > img {
                        opacity: 0;
                    }
                }
            `)
        }

        function iterateAllPosts() {
            //Get ALL posts (this does NOT include inlined posts and hovered posts)
            const allPosts = document.querySelectorAll("#divThreads > .opCell > .innerOP, .divPosts > .postCell");
            const postsArray = Array.from(allPosts); //use an array to find the last post
            postsArray.forEach((post, index) => {
                if (index == postsArray.length-1) {
                    //only the last post sends batching=false
                    iterateSinglePost(post, true, false);
                } else {
                    iterateSinglePost(post, true, true);
                }
            });
        }

        /**
         * Adds .yourPost to your posts and adds .replyPost to reply posts. 
         * Works on new posts, tooltip posts, and inline posts. 
         * 
         * .yourPost: if .innerPost:has(> .postInfo.title > .youName)
         * 
         * .replyPost: if .innerPost:has(> .divMessage .quoteLink.you)
         */
        function setPostType(node) {
            const inner = node.querySelector(":scope > .innerPost");
            if (!inner) return;

            // Check for your post
            if (inner.querySelector(":scope > .postInfo.title > .youName")) {
                inner.classList.add("yourPost");
            } else if (inner.querySelector(":scope > .divMessage .quoteLink.you")) {
                inner.classList.add("replyPost");
            } else {
                //Testing: mark hovered posts as replies (missing from native js)
                try {
                    if (!windowAccessible) return; //Return if the window is not accessible
                    if (node.classList.contains("quoteTooltip") && window_posting?.yous) {
                        const quoteLinks = inner.querySelectorAll(".quoteLink");
                        for (const qlink of quoteLinks) {
                            const extractedNumber = parseInt(qlink.textContent.replace(/[^\d]/g, ''), 10);
                            if (!isNaN(extractedNumber) && window_posting.yous.includes(extractedNumber)) {
                                qlink.classList.add("you");
                                inner.classList.add("replyPost");
                                // break;
                            }
                        }
                    }
                } catch (error) {
                    console.log("Lynx-- ~ setPostType ~ error when trying to parse tooltip:", error);
                }
            }
        }

        /**
         * Processes a single post element.
         *
         * @param {HTMLElement} post - The post here can be an .innerPost or one of its containers
         * @param {boolean} newpost - True if this is a new post in the thread (i.e. not a tooltip or inline)
         * @param {boolean} batching - False if this is not from a batch from iterateAllPosts (or not the last post of the batch)
         */
        function iterateSinglePost(post, newpost = true, batching = false) {
            // console.log("Lynx-- processing post", {post}, {newpost}, {batching});
            indicateCrossLinks(post);
            addDeletedChecks(post);
            imageSearchHooks(post);
            if (settings.markPostEdge)
                setPostType(post);
            if (settings.glowFirstPostByID)
                glowpost(post, newpost);
            if (settings.spoilerImageType.startsWith("reveal"))
                revealSpoilerImages(post);
            if (settings.showPostIndex)
                addPostCount(post, newpost);

            //Run only if its a new post in the thread
            if (newpost) {
                if (settings.spoilerImageType=="thread" || settings.spoilerImageType=="threadAlt")
                    setThreadSpoiler(post);
                //This still has to iterate all posts, do it last and only when necessary.
                //These are manually ran in the mutation observer
                if (batching === false && settings.showScrollbarMarkers)
                    recreateScrollMarkers();
            }
        }

        //Start running and observing, no need for a delay.
        iterateAllPosts();
        //Observe posts and all their children
        const observer = new MutationObserver((mt_callback) => {
            let foundNewPost = false;
            mt_callback.forEach(mut => {
                if (mut.type == "childList" && mut.addedNodes?.length > 0) {
                    //console.log("MutationObserver!!!");
                    mut.addedNodes.forEach(node => {
                        //New posts, new inlined posts, new hovered posts all contain .innerPost and are always in a div container.
                        //New posts are div.postCell and new inlines are div.inlineQuote
                        if (node.tagName === "DIV" && node.querySelector(".innerPost,.innerOP")) {
                            // console.log("lynx ~ observer:", {node}, {mut});
                            if (node.classList?.contains("postCell")) {
                                foundNewPost = true;
                                iterateSinglePost(node, true, true); //batching=true for both
                            } else {
                                iterateSinglePost(node, false, true);
                            }
                        }
                    });
                }
            });
            //Manually run all batching=false actions here
            if (foundNewPost && settings.showScrollbarMarkers) {
                recreateScrollMarkers();
            }
        });
        observer.observe(document.querySelector(".divPosts"), {childList: true, subtree: true});

        //Observe the hover tooltip (ignore everything else)
        const toolObserver = new MutationObserver((mutationsList) => {
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach(node => {
                        if (node.classList?.contains("quoteTooltip")) {
                            //New hover tooltip div.quoteTooltip found
                            iterateSinglePost(node, false);
                        }
                    });
                }
            }
        });
        toolObserver.observe(document.body, {childList: true});

    } //End of runAfterDom()

    //Starting runAfterDom when the document is ready
    waitForDom(runAfterDom);
})();