MeFi Navigator Redux

MetaFilter: navigate through users' comments, and highlight comments by OP and yourself

// ==UserScript==
// @name         MeFi Navigator Redux
// @namespace    https://github.com/klipspringr/mefi-userscripts
// @version      2025-08-29-a
// @description  MetaFilter: navigate through users' comments, and highlight comments by OP and yourself
// @author       Klipspringer
// @supportURL   https://github.com/klipspringr/mefi-userscripts
// @license      MIT
// @match        *://*.metafilter.com/*
// @grant        none
// ==/UserScript==

;(() => {
    "use strict"

    if (
        !/^\/(\d|comments\.mefi)/.test(window.location.pathname) ||
        /rss$/.test(window.location.pathname)
    ) {
        return
    }

    const SVG_UP = `<svg xmlns="http://www.w3.org/2000/svg" hidden style="display:none"><path id="mfnr-up" fill="currentColor" d="M 0 93.339 L 50 6.661 L 100 93.339 L 50 64.399 L 0 93.339 Z" /></svg>`
    const SVG_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" hidden style="display:none"><path id="mfnr-down" fill="currentColor" d="M 100 6.69 L 50 93.31 L 0 6.69 L 50 35.607 L 100 6.69 Z" /></svg>`

    // CSS notes:
    // - mfnr-op needs to play nicely with .mod in threads where OP is a mod
    // - classic theme has different margins from modern, so we can't change margin-left without knowing what theme we're on
    // - relative positioning seems to work better
    const INJECTED_CSS = `<style>
        .mfnr-op {
            border-left: 5px solid #0004 !important;
            padding-left: 10px !important;
            position: relative !important;
            left: -15px !important;
        }
        @media (max-width: 550px) {
            .mfnr-op {
                left: -5px !important;
            }
        }
        .mfnr-me {
            background-color: #C8E0A1;
            border-radius: 2px;
            color: #223C23;
            font-size: 0.8em;
            margin-left: 4px;
            padding: 0 4px;
        }
        .mfnr-nav {
            white-space: nowrap;
        }
        .mfnr-nav svg {
            vertical-align: middle;
            top: -1px;
        }
        </style>`

    const getCookie = (key) => {
        const s = RegExp(key + "=([^;]+)").exec(document.cookie)
        if (!s || !s[1]) return null
        return decodeURIComponent(s[1])
    }

    const markCommentByMe = (targetNode) => {
        // check we haven't added a badge already
        if (targetNode.querySelector("span.mfnr-me")) return

        const span = document.createElement("span")
        span.classList.add("mfnr-me")
        span.textContent = "me"
        targetNode.appendChild(span)
    }

    const markCommentByPoster = (targetNode) =>
        targetNode.parentElement.parentElement.classList.add("mfnr-op")

    const createNavigateLink = (svgHref) => {
        const a = document.createElement("a")
        const svg = document.createElementNS(
            "http://www.w3.org/2000/svg",
            "svg"
        )
        svg.setAttribute("width", "1em")
        svg.setAttribute("viewBox", "0 0 100 100")
        svg.setAttribute("class", "mfnr-nav")
        const use = document.createElementNS(
            "http://www.w3.org/2000/svg",
            "use"
        )
        use.setAttribute("href", "#" + svgHref)
        svg.appendChild(use)
        a.appendChild(svg)
        return a
    }

    const run = (firstRun) => {
        const start = performance.now()

        // if not first run, remove any existing navigators (from both post and comments)
        if (!firstRun) {
            document
                .querySelectorAll("span.mfnr-nav")
                .forEach((n) => n.remove())
        }

        // get post node
        // query tested on all subsites, modern and classic, 2025-04-10
        const postNode = document.querySelector(
            "div.copy > span.smallcopy > a:first-child"
        )

        if (!postNode) throw Error("Failed to find postNode")

        const poster = postNode.firstChild.textContent.trim()

        // map users to their comments. initialise with the post
        const mapUsersBylines = new Map([
            [poster, [{ node: postNode, anchor: "#top" }]],
        ])

        // get comment nodes, excluding live preview
        // query tested on all subsites, modern and classic, 2025-04-10
        const commentNodes = document.querySelectorAll(
            "div.comments:not(#commentform *) > span.smallcopy > a:first-child"
        )

        commentNodes.forEach((node) => {
            const user = node.firstChild.textContent.trim() // get firstChild so we ignore any badges, e.g. "me"

            const anchorElement =
                node.parentElement.parentElement.previousElementSibling

            const anchor = "#" + anchorElement.getAttribute("name")

            const bylines = mapUsersBylines.get(user)
            if (bylines) {
                bylines.push({ node, anchor })
            } else {
                mapUsersBylines.set(user, [{ node, anchor }])
            }
        })

        mapUsersBylines.forEach((bylines, user) => {
            bylines.forEach(({ node }, i) => {
                if (i > 0 && me !== null && user === me) markCommentByMe(node)

                // highlight poster comments, unless subsite has this built in
                if (
                    i > 0 &&
                    subsite !== "ask" &&
                    subsite !== "projects" &&
                    user === poster
                )
                    markCommentByPoster(node)

                if (bylines.length <= 1) return

                const navigator = document.createElement("span")
                navigator.setAttribute("class", "mfnr-nav")

                const nodes = ["["]

                const previous = bylines[i - 1]?.anchor
                if (previous) {
                    const clone = navigatePrevious.cloneNode(true)
                    clone.setAttribute("href", previous)
                    nodes.push(clone)
                }

                nodes.push(bylines.length)

                const next = bylines[i + 1]?.anchor
                if (next) {
                    const clone = navigateNext.cloneNode(true)
                    clone.setAttribute("href", next)
                    nodes.push(clone)
                }

                nodes.push("]")

                navigator.append(...nodes)

                node.parentElement.appendChild(navigator)
            })
        })

        console.log(
            "mefi-navigator-redux",
            firstRun ? "first-run" : "new-comments",
            1 + commentNodes.length,
            Math.round(performance.now() - start) + "ms"
        )
    }

    document.body.insertAdjacentHTML("beforeend", SVG_UP + SVG_DOWN)
    document.body.insertAdjacentHTML("beforeend", INJECTED_CSS)

    const navigatePrevious = createNavigateLink("mfnr-up")
    const navigateNext = createNavigateLink("mfnr-down")

    const subsite = window.location.hostname.split(".", 1)[0]

    const me = getCookie("USER_NAME")

    const newCommentsElement = document.getElementById("newcomments")
    if (newCommentsElement) {
        const observer = new MutationObserver(() => run(false))
        observer.observe(newCommentsElement, { childList: true })
    }

    run(true)
})()