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.

  • Want to keep track of this thread?
    Accounts can bookmark posts, watch threads for updates, and jump back to where you stopped reading.
    Create account

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