Another Kiwifarms Userscript: Preview Chat User's Profile - Hover over a chatter to view a preview of their profile! Easily click on members to view their profiles in depth.

PoonBrosAreValid

kiwifarms.net
Joined
Sep 15, 2023
I've been working on another userscript! (Thanks @Sicko Hunter for the idea)

This one allows you to hover over chatters avatars and get a live preview of their profile.

preview-chat-user.png
preview-chat-user-2.png


Give it a try using Tampermonkey or Greasemonkey (Kiwis have reported bugs with violent monkey on my previous scripts). Report any bugs / improvements here.

JavaScript:
// ==UserScript==
// @name         Kiwifarms Chat Profile Preview
// @namespace    http://tampermonkey.net/
// @version      2025-09-01
// @description  Adds a preview popup for chat users
// @author       PoonBrosAreValid
// @match        https://kiwifarms.st/chat/*
// @match        https://kiwifarms.st/
// @icon         https://kiwifarms.st/styles/custom/logos/kiwi_square.og.png
// @grant        none
// ==/UserScript==

const BACKGROUND_COLOR = "#40444B"
const BACKGROUND_COLOR_ERROR = "#660000"
const TEXT_COLOR_MUTED = "#b3b3b3"

const state = {
    previewCache: {},
    isPreviewLoading: false,
    mouseX: 0,
    mouseY: 0,
}

const ui = {
    /**
     * The iframe that contains the chat
     * @return {HTMLIFrameElement}
     */
    get frame() {
        const shim = document.querySelector("#rust-shim")
        if (! shim) {
            throw new Error("Chat hasn't loaded")
        }
        return shim
    },

    /**
     * The document element within the chat iframe
     * (note: this has its own coordinates that must be calculated for)
     * @return {Window.Document}
     */
    get document() {
        return this.frame.contentWindow.document
    },

    /**
     * The `main` element in the chat frame
     * @return {HTMLElement}
     */
    get chatMain() {
        return this.document.querySelector("#chat")
    },

    /**
     * Get or create an element used as the preview popup
     * @return {HTMLDivElement}
     */
    get profilePreview() {
        const got = document.querySelector(".profile-header-preview")
        if (got) {
            return got
        }
        const preview = document.createElement("div")
        preview.classList.add("profile-header-preview")
        preview.innerText = "Loading..."
        return preview
    },

    /**
     * @return {HTMLDivElement}
     */
    buildArrow(color = BACKGROUND_COLOR) {
        const arrow = document.createElement("div")
        arrow.style.position = 'absolute';
        arrow.style.bottom = '-10px';
        arrow.style.left = '20px';
        arrow.style.width = '0';
        arrow.style.height = '0';
        arrow.style.borderLeft = '10px solid transparent';
        arrow.style.borderRight = '10px solid transparent';
        arrow.style.borderTop = `10px solid ${color}`
        return arrow
    },

    /**
     * @return {HTMLDivElement}
     */
    buildPreview() {
        const preview = this.profilePreview
        preview.innerText = "Loading..."
        preview.style.backgroundColor = BACKGROUND_COLOR
        preview.style.padding = "10px"
        preview.style.borderRadius = "10px"
        preview.style.position = "absolute"
        preview.style.zIndex = "10000"
        preview.style.maxWidth = "400px"
        preview.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)"

        // Position initially - will be repositioned after content loads
        this.positionPreview(preview)
        return preview
    },

    hideProfileHeader({ignoreLoading} = {ignoreLoading: false}) {
        if (state.isPreviewLoading && !ignoreLoading) return;
        ui.profilePreview.remove()
    },

    /** Add content to the preview */
    setPreviewContent(preview, content, color) {
        preview.innerText = ""
        preview.append(content, this.buildArrow(color))
        preview.style.backgroundColor = color

        // Reposition after content is added
        this.positionPreview(preview)
    },

    /** Position preview near mouse with proper bounds checking */
    positionPreview(preview) {
        // Ensure preview is in DOM to get dimensions
        if (!preview.parentNode) {
            document.body.appendChild(preview)
        }

        // Get current dimensions
        const previewRect = preview.getBoundingClientRect()
        const windowWidth = window.innerWidth
        const windowHeight = window.innerHeight
        const scrollY = window.pageYOffset
        const scrollX = window.pageXOffset

        let x = state.mouseX + 15 // Small offset from cursor
        let y = state.mouseY - previewRect.height - 15 // Above cursor

        // Boundary checking - keep preview on screen
        if (x + previewRect.width > windowWidth + scrollX) {
            x = state.mouseX - previewRect.width - 15 // Move to left of cursor
        }

        if (x < scrollX) {
            x = scrollX + 10 // Keep some margin from edge
        }

        if (y < scrollY) {
            y = state.mouseY + 15 // Below cursor if no room above
        }

        if (y + previewRect.height > windowHeight + scrollY) {
            y = windowHeight + scrollY - previewRect.height - 10
        }

        preview.style.left = x + "px"
        preview.style.top = y + "px"

        // Adjust arrow position based on preview position relative to mouse
        const arrow = preview.querySelector('div[style*="border"]')
        if (arrow) {
            const previewLeft = parseInt(preview.style.left)
            const arrowLeft = Math.max(10, Math.min(previewRect.width - 30, state.mouseX - previewLeft))
            arrow.style.left = arrowLeft + "px"

            // Flip arrow if preview is below cursor
            if (y > state.mouseY) {
                arrow.style.bottom = "auto"
                arrow.style.top = "-10px"
                arrow.style.borderTop = "none"
                arrow.style.borderBottom = `10px solid ${preview.style.backgroundColor}`
            }
        }
    }
}

const chat = {
    buildUserUrl(id) {
        return `${location.origin}/members/${id}`
    },

    /** Formatted like "chat-activity-{{USER_ID}}" */
    urlFromActivityId(activityId) {
        const userId = activityId.replace('chat-activity-', '')
        return this.buildUserUrl(userId)
    },

    /** Formatted like "/blah/blah/blah/{{USER_ID}}.jpg" */
    urlFromMessageAvatarSrc(src) {
        const path = new URL(location.origin + src).pathname
        const pieces = path.split('/')
        const userId = pieces[pieces.length - 1].replace('.jpg', '')
        return this.buildUserUrl(userId)
    },

    /** Create and click a fake link to avoid popup warnings */
    openProfile(url) {
        const fakeLink = document.createElement('a')
        fakeLink.href = url
        fakeLink.target = '_blank'
        fakeLink.click()
    },

    /** Get a little preview element of a users profile
     * This is just a trimmed version of the memebers page
     * @param {string} url
     */
    async fetchProfileHeader(url) {
        const cached = state.previewCache[url]
        if (cached) return { fromCache: true, content: cached.cloneNode(true)}

        const res = await fetch(url);
        if (res.status === 203) {
          throw new Error(`Reload to solve KiwiFlare...`);
        } else if (res.status !== 200) {
          throw new Error(`Page responded error ${res.status}`);
        }

        const text = await res.text()
        const dummyElement = document.createElement("div")
        dummyElement.innerHTML = text
        const header = dummyElement.querySelector(".memberHeader")

        if (!header) {
            throw new Error("Could not find member header")
        }

        const messageCountEl = header.querySelector(".memberHeader-stats .fauxBlockLink dd a.fauxBlockLink-linkRow")
        const messageCount = messageCountEl ? messageCountEl.innerText : "N/A"
        const mainContent = header.querySelector(".memberHeader-mainContent")

        if (!mainContent) {
            throw new Error("Could not find member content")
        }

        // Delete report button
        for (const btn of mainContent.querySelectorAll(".buttonGroup")) {
            const btnText = btn.querySelector(".button-text")
            if (btnText && btnText.innerText.trim() === "Report") {
                btn.remove()
            }
        }

        const messagesContainer = document.createElement("span")
        const messageLabel = document.createElement("span")
        messageLabel.style.color = TEXT_COLOR_MUTED
        messageLabel.innerText = "Messages: "

        messagesContainer.append(messageLabel, messageCount)
        mainContent.append(messagesContainer)

        state.previewCache[url] = mainContent.cloneNode(true)
        return { fromCache: false, content: mainContent }
    },

    async displayProfileHeader(url) {
        if (state.isPreviewLoading) {
            return
        }
        ui.profilePreview.remove()

        state.isPreviewLoading = true
        const preview = ui.buildPreview()
        preview.innerText = "Loading..."
        preview.append(ui.buildArrow())
        document.body.append(preview)

        try {
            const {content, fromCache} = await this.fetchProfileHeader(url)

            // Always rebuild preview to ensure proper positioning
            ui.profilePreview.remove()
            const newPreview = ui.buildPreview()
            newPreview.innerText = "Loading..."
            newPreview.append(ui.buildArrow())
            document.body.append(newPreview)

            ui.setPreviewContent(newPreview, content, BACKGROUND_COLOR)

        } catch (error) {
            const content = document.createElement("div")
            content.innerText = error.toString()
            ui.setPreviewContent(preview, content, BACKGROUND_COLOR_ERROR)
        }

        state.isPreviewLoading = false
    },

    addAvatarClick(avatar, url) {
        if (avatar.dataset.hasListener) return

        const link = document.createElement('a')
        link.href = url
        link.target = '_blank'
        avatar.classList.add('clickable-avatar')
        avatar.parentElement.prepend(link)
        link.append(avatar)
        avatar.dataset.hasListener = "true"
    },

    addAvatarHover(avatar, url) {
        if (avatar.dataset.hasHoverListener) return;
        avatar.addEventListener("mouseenter", () => this.displayProfileHeader(url))
        avatar.addEventListener("mouseleave", () => ui.hideProfileHeader())
        avatar.dataset.hasHoverListener = "true"
    },

    observe(selector, callback) {
        const observerOptions = {
            childList: true,
            subtree: true,
        }

        const elem = ui.document.querySelector(selector)
        if (!elem) return

        const observer = new MutationObserver(callback, observerOptions)
        observer.observe(elem, observerOptions)
    },
}

function onMembersChange(event) {
    const unfilledLinks = ui.document.querySelectorAll('.chat .activity a:not([href])')

    for (const link of unfilledLinks) {
        const activityId = link.parentElement.id
        const profileUrl = chat.urlFromActivityId(activityId)
        link.href = profileUrl
        link.style.textDecoration = 'none'
        link.style.color = "white"
        link.target = '_blank'

        const username = link.innerText
        link.title = `Go to ${username}'s profile`

        const avatar = link.parentElement.querySelector('.avatar')
        if (avatar) {
            chat.addAvatarClick(avatar, profileUrl)
        }
    }
}

function onMessagesChange(event) {
    const avatars = ui.document.querySelectorAll('.chat-message .avatar:not(.clickable-avatar)')

    for (const avatar of avatars) {
        const url = chat.urlFromMessageAvatarSrc(avatar.src)
        chat.addAvatarClick(avatar, url)
        chat.addAvatarHover(avatar, url)
    }
}

/** Track the mouse accurately */
function trackMouse() {
    // Track mouse on main document
    document.addEventListener("mousemove", (event) => {
        state.mouseX = event.pageX
        state.mouseY = event.pageY
    })

    // Track mouse in iframe when it loads
    ui.frame.addEventListener("load", () => {
        ui.document.addEventListener("mousemove", (event) => {
            const frameRect = ui.frame.getBoundingClientRect();
            state.mouseX = frameRect.left + event.clientX + window.pageXOffset;
            state.mouseY = frameRect.top + event.clientY + window.pageYOffset;
        });

        ui.chatMain.addEventListener("mouseleave", () => {
            ui.hideProfileHeader({ ignoreLoading: true })
        })
    });
}

function init() {
    // Try to grab the frame and observe the chat
    let timerInit = setInterval(() => {
        if (!ui.frame) return
        if (ui.document.readyState !== 'complete') return

        chat.observe('#chat-activity', onMembersChange)
        chat.observe('#chat-messages', onMessagesChange)

        clearInterval(timerInit)
    }, 500)

    trackMouse()
}

init()

If you like this script, you may also like my Infinite Scroll Script that allows you to have a Twitter / Instagram like experience. Also checkout my Thread Summary Script that allows you to see all the thread highlights in a twitter like scroll experience.

Edit: Fix bug that would sometimes not load preview properly
Edit: Better error handling
Edit: More accurate positioning of popup
Edit: Fixed bug of preview not showing when loading speed is slow
Edit: Updated the styling and made it more stable and less buggy
 
Last edited:
Finally, we can have a Discord experience on a superior platform!
 
it'd be great if you hooked it up to AI and let it parse through the users post history to generate a short summary of said posters posting, think about the possibilities
ChatGPT: It looks like this user is le unheckin transphobic! Reporting to the police ASAP! Thanks for finding the meanie for me, I Hate Women +100 social credit score!
 
// ==UserScript==
// @Name Kiwifarms Chat Profile Preview
// @namespace http://tampermonkey.net/
// @version 2025-03-15
// @description Adds a preview popup for chat users
// @author PoonBrosAreValid
// @match https://kiwifarmsaaf4t2h7gc3dfc5ojhmqruw2nit3uejrpiagrxeuxiyxcyd.onion/chat/*
// @match https://kiwifarmsaaf4t2h7gc3dfc5ojhmqruw2nit3uejrpiagrxeuxiyxcyd.onion/
// @icon https://kiwifarmsaaf4t2h7gc3dfc5ojh....onion/styles/custom/logos/kiwi_square.og.png
// @grant none
// ==/UserScript==

For Tor users
 
lol at the time when there was a similar thread a few months back and Jersh completely disabled the notifications page. Nobody let him see this thread :stress:
 
lol at the time when there was a similar thread a few months back and Jersh completely disabled the notifications page. Nobody let him see this thread :stress:
Mine uses a cache system to make sure it only makes 1 request per user. I could even save this cache to local storage to make it more efficient. It shouldn't DDOS Null hopefully. I can't imagine enough people will use the script to make a big difference. Please don't get mad nullerino.
 
Back
Top Bottom