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.
Give it a try using Tampermonkey or Greasemonkey (Kiwis have reported bugs with violent monkey on my previous scripts). Report any bugs / improvements here.
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
This one allows you to hover over chatters avatars and get a live preview of their profile.
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-03-15
// @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"
const previewRect = preview.getBoundingClientRect();
this.setByMouse(preview, {offsetX: 0, offsetY: previewRect.height + 20})
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
const previewRect = preview.getBoundingClientRect();
this.setByMouse(preview, {offsetX: 0, offsetY: previewRect.height + 20})
},
/** Move an item by the mouse, factoring in offsets from the chat frame */
setByMouse(elem, {offsetX, offsetY}) {
const { scrollY } = this.frame.contentWindow
elem.style.position = "absolute"
elem.style.left = (state.mouseX - offsetX) + "px"
elem.style.top = (state.mouseY - scrollY - offsetY) + "px"
}
}
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}
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")
const messageCount = header.querySelector(".memberHeader-stats .fauxBlockLink dd a.fauxBlockLink-linkRow").innerText
const mainContent = header.querySelector(".memberHeader-mainContent")
// Delete report button
for (const btn of mainContent.querySelectorAll(".buttonGroup")) {
const btnText = btn.querySelector(".button-text")
if (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
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)
if (! fromCache) {
ui.profilePreview.remove()
const prev = ui.buildPreview()
prev.innerText = "Loading..."
prev.append(ui.buildArrow())
document.body.append(prev)
ui.setPreviewContent(prev, content, BACKGROUND_COLOR)
} else {
ui.setPreviewContent(preview, content, BACKGROUND_COLOR)
}
} catch (error) {
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)
},
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)
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 (because the chat is in an iframe that messes the positions up) */
function trackMouse() {
document.addEventListener("mousemove", (event) => {
state.mouseX = event.pageX
state.mouseY = event.pageY
})
ui.frame.addEventListener("load", () => {
ui.document.addEventListener("mousemove", (event) => {
const frameRect = ui.frame.getBoundingClientRect();
const pageX = frameRect.left + event.clientX + window.scrollX;
const pageY = frameRect.top + event.clientY + window.scrollY;
state.mouseX = pageX;
state.mouseY = pageY;
});
ui.chatMain.addEventListener("mouseleave" , () => {
// special case because sometimes the mouse leave event on the profile pic
// is missed
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
Last edited: