// ==UserScript==
// @name GGn TheLounge User Data Enhancement
// @version 2.4
// @author SleepingGiant
// @description Append GGn class emoji to usernames with a customizable UI
// @namespace https://gf.qytechs.cn/users/1395131
// @include *:9000/*
// @grant GM.xmlHttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.listValues
// @connect gazellegames.net
// ==/UserScript==
(function () {
'use strict';
if (typeof window.requestIdleCallback !== 'function') {
window.requestIdleCallback = function (cb) { return setTimeout(cb, 0); };
}
console.debug("GGn TheLounge User Data Enhancement starting...");
const API_URL = 'https://gazellegames.net/api.php';
const RATE_LIMIT_MS = 3000;
const CACHE_DURATION_MS = 36 * 60 * 60 * 1000; // 1.5 days. Makes loading smoother and people's user classes don't change much.
let lastApiCallTime = Date.now();
const uncachedUsers = new Set();
const defaultClassToEmoji = {
"Amateur": "👶",
"Gamer": "🎲",
"Pro Gamer": "🕹️",
"Elite Gamer": "🎮",
"Legendary Gamer": "🌟",
"Master Gamer": "⚡",
"Gaming God": "🏆",
"Staff Trainee": "🛠️",
"Moderator": "🧹",
"Senior Moderator": "🛡️",
"Team Leader": "🥇",
"Junior Developer": "💻",
"Senior Developer": "💻",
"SysOp": "⚙️",
"Administrator": "👑",
"Uploader": "📦",
"VIP": "💎",
"VIP+": "💎",
"Legend": "💎",
"Bot": "🤖",
"undefined": "❓",
};
let classToEmoji = {};
async function loadClassMap() {
console.debug("Loading class emoji map...");
const saved = await GM.getValue("customClassMap");
classToEmoji = saved ? JSON.parse(saved) : { ...defaultClassToEmoji };
console.debug("Loaded classToEmoji:", classToEmoji);
}
async function saveClassMap() {
console.debug("Saving class emoji map:", classToEmoji);
await GM.setValue("customClassMap", JSON.stringify(classToEmoji));
}
async function getApiKey() {
console.debug("Getting API key...");
let apiKey = await GM.getValue("apiKey");
if (!apiKey) {
apiKey = prompt("Enter your GazelleGames API key. Required permissions: \"User\":")?.trim();
if (apiKey) await GM.setValue("apiKey", apiKey);
}
return apiKey;
}
async function getCached(username) {
const key = `userCache_${username}`;
const raw = await GM.getValue(key);
if (!raw) return null;
const { timestamp, data } = JSON.parse(raw);
const expired = (Date.now() - timestamp) > CACHE_DURATION_MS;
if (!expired && data && Object.keys(data).length > 0) return data;
console.debug(`Cache miss or expired for ${username}`);
return null;
}
async function setCached(username, data) {
console.debug(`Caching data for user: ${username}`, data);
const key = `userCache_${username}`;
await GM.setValue(key, JSON.stringify({ timestamp: Date.now(), data }));
}
async function fetchUserInfo(username) {
console.debug(`Fetching user info for: ${username}`);
const now = Date.now();
if ((now - lastApiCallTime) < RATE_LIMIT_MS) {
console.debug("Rate limit hit, skipping request");
return null;
}
lastApiCallTime = now;
const apiKey = await getApiKey();
if (!apiKey) {
console.debug("No API key available");
return null;
}
const url = `${API_URL}?key=${encodeURIComponent(apiKey)}&request=user&name=${encodeURIComponent(username)}`;
return new Promise(resolve => {
GM.xmlHttpRequest({
method: 'GET',
url,
onload: res => {
try {
const data = JSON.parse(res.responseText);
console.log("Response data:")
console.log(data);
if (res.status !== 200 || data?.status === "failure") {
console.debug("API error response:", data);
resolve(data?.error === "no such user" ? { invalidUser: true } : null);
} else resolve(data);
} catch {
resolve(null);
}
},
onerror: (e) => {
console.debug("Request error:", e);
resolve(null);
}
});
});
}
async function annotateUser(userEl) {
const username = userEl.getAttribute('data-name');
if (!username || userEl.dataset.emojiAppended === "true") return;
let data = await getCached(username);
if (!data) {
console.debug(`User ${username} not cached, adding to queue`);
uncachedUsers.add(username);
return;
}
const personal = data?.response?.personal;
const userClass = personal?.class;
const warned = personal?.warned;
const emoji = classToEmoji[userClass];
const prependEmoji = warned ? "⚠️" : "";
userEl.dataset.emojiAppended = "true";
if (emoji) userEl.dataset.emoji = emoji;
if (prependEmoji) userEl.dataset.warnEmoji = prependEmoji;
userEl.classList.add('user-with-emoji');
}
async function scanRecentMessages() {
console.debug("Scanning recent messages...");
const seen = new WeakSet();
const messages = Array.from(document.querySelectorAll('.msg[data-type="message"] .user')).reverse();
for (const userEl of messages) {
if (!seen.has(userEl) && userEl.dataset.emojiAppended !== "true") {
seen.add(userEl);
requestIdleCallback(() => annotateUser(userEl));
}
}
}
function init() {
console.debug("Initializing message observer...");
const msgContainer = document.querySelector('.messages');
if (!msgContainer) {
console.debug("No message container found.");
return;
}
const style = document.createElement('style');
style.textContent = `
.user-with-emoji::before {
content: attr(data-warn-emoji);
pointer-events: none;
user-select: none;
margin-right: 2px;
}
.user-with-emoji::after {
content: " " attr(data-emoji);
pointer-events: none;
user-select: none;
}
`;
document.head.appendChild(style);
new MutationObserver(scanRecentMessages).observe(msgContainer, { childList: true, subtree: true });
scanRecentMessages();
}
(async () => {
console.debug("Running main async block");
await loadClassMap();
const appCheck = setInterval(() => {
const app = document.getElementById('app');
if (app) {
clearInterval(appCheck);
console.debug("App container found, initializing UI");
addEmojiEditButton();
init();
observeUserContextMenu();
}
}, 100);
// Background fetching loop. If you see 50 uncached users on first load for example, queue them up and iterate over, makes initial load easier.
setInterval(async () => {
const [username] = uncachedUsers;
if (!username) return;
const data = await fetchUserInfo(username);
if (data) {
await setCached(username, data);
uncachedUsers.delete(username); // only now!
scanRecentMessages(); // will paint the emoji
}
}, RATE_LIMIT_MS);
setInterval(async () => {
scanRecentMessages();
}, 60000) // Every 60 seconds do a rescan. Force pulse check (attempting to debug emojis stopping rendering. This does not need to stay)
})();
// ------------ Custom Emoji Edit button below (nothing to do with actual user polling) ----------
function createSettingsUI() {
console.debug("Creating settings UI");
const getSelectedFields = () => JSON.parse(localStorage.getItem('selectedUserFields') || '[]');
const setSelectedFields = (fields) => localStorage.setItem('selectedUserFields', JSON.stringify(fields));
const allFields = {
isFriend: "Is Friend",
gold: "Gold",
profile: "Profile Link",
joinedDate: "Joined Date",
uploaded: "Uploaded",
downloaded: "Downloaded",
fullDownloaded: "Full Downloaded",
ratio: "Ratio",
shareScore: "Share Score",
class: "Class",
donor: "Donor",
warned: "Warned",
paranoia: "Paranoia",
torrentsUploaded: "Torrents Uploaded",
hourlyGold: "Hourly Gold",
actualPosts: "Raw Forum Posts",
threads: "Threads",
forumLikes: "Forum Likes",
forumDislikes: "Forum Dislikes",
ircActualLines: "Raw IRC Lines",
seedSize: "Seed Size"
};
const modal = document.createElement('div');
modal.style = `
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: #fff; color: #000;
border: 2px solid #ccc; border-radius: 10px;
padding: 0; z-index: 9999; width: 400px;
box-shadow: 0 0 10px rgba(0,0,0,0.3);
overflow: hidden;
font-family: sans-serif;
`;
const titleBar = document.createElement('div');
titleBar.style = `
background-color: #eee;
padding: 10px 40px 10px 10px;
font-weight: bold;
cursor: move;
border-bottom: 1px solid #ccc;
position: relative;
`;
titleBar.textContent = 'Edit Viewed Details';
const closeBtn = document.createElement('span');
closeBtn.innerHTML = '×';
closeBtn.style = `
position: absolute;
top: 8px;
right: 12px;
color: red;
font-size: 18px;
font-weight: bold;
cursor: pointer;
`;
closeBtn.onclick = () => modal.remove();
titleBar.appendChild(closeBtn);
const content = document.createElement('div');
content.style.padding = '10px';
content.innerHTML = `
<div id="emojiForm" style="max-height: 300px; overflow-y: auto;"></div>
<div style="display: flex; justify-content: center; gap: 10px; margin-top: 5px;">
<button id="resetBtn" style="
background-color: #f44336;
color: white;
padding: 6px 12px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
">Reset</button>
<button id="saveBtn" style="
background-color: #4CAF50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
">Save</button>
</div>
`;
modal.appendChild(titleBar);
modal.appendChild(content);
document.body.appendChild(modal);
let isDragging = false;
let dragOffsetX = 0, dragOffsetY = 0;
titleBar.addEventListener('mousedown', (e) => {
isDragging = true;
dragOffsetX = e.clientX - modal.offsetLeft;
dragOffsetY = e.clientY - modal.offsetTop;
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
modal.style.left = `${e.clientX - dragOffsetX}px`;
modal.style.top = `${e.clientY - dragOffsetY}px`;
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
const formContainer = content.querySelector('#emojiForm');
// Emoji editor
Object.entries(classToEmoji).forEach(([cls, emoji]) => {
const row = document.createElement('div');
row.innerHTML = `
<label style="display:flex;justify-content:space-between;margin-bottom:6px">
<span>${cls}</span>
<input type="text" value="${emoji}" data-class="${cls}" style="width: 50px; text-align:center" />
</label>
`;
formContainer.appendChild(row);
});
// Extra Fields section
const selected = new Set(getSelectedFields());
const extraOptions = document.createElement('div');
extraOptions.style.marginTop = '10px';
extraOptions.innerHTML = `<hr/><div><strong>Show Extra Fields:</strong></div>`;
Object.entries(allFields).forEach(([key, label]) => {
const wrapper = document.createElement('label');
wrapper.style.display = 'block';
wrapper.innerHTML = `
<input type="checkbox" value="${key}" ${selected.has(key) ? 'checked' : ''} />
${label}
`;
extraOptions.appendChild(wrapper);
});
formContainer.appendChild(extraOptions);
// Save button logic
content.querySelector('#saveBtn').onclick = async () => {
const inputs = formContainer.querySelectorAll('input[type="text"]');
classToEmoji = {};
inputs.forEach(input => {
const cls = input.getAttribute('data-class');
if (cls) classToEmoji[cls] = input.value;
});
const checkedFields = Array.from(extraOptions.querySelectorAll('input[type="checkbox"]:checked')).map(el => el.value);
setSelectedFields(checkedFields);
await saveClassMap();
modal.remove();
};
// Reset button logic
content.querySelector('#resetBtn').onclick = () => {
classToEmoji = { ...defaultClassToEmoji };
localStorage.removeItem('selectedUserFields');
formContainer.innerHTML = '';
// Rebuild emoji inputs
Object.entries(classToEmoji).forEach(([cls, emoji]) => {
const row = document.createElement('div');
row.innerHTML = `
<label style="display:flex;justify-content:space-between;margin-bottom:6px">
<span>${cls}</span>
<input type="text" value="${emoji}" data-class="${cls}" style="width: 50px; text-align:center" />
</label>
`;
formContainer.appendChild(row);
});
// Rebuild extra fields
const newExtraOptions = document.createElement('div');
newExtraOptions.style.marginTop = '10px';
newExtraOptions.innerHTML = `<hr/><div><strong>Show Extra Fields:</strong></div>`;
Object.entries(allFields).forEach(([key, label]) => {
const wrapper = document.createElement('label');
wrapper.style.display = 'block';
wrapper.innerHTML = `
<input type="checkbox" value="${key}" />
${label}
`;
newExtraOptions.appendChild(wrapper);
});
formContainer.appendChild(newExtraOptions);
};
}
function addEmojiEditButton() {
console.debug("Attempting to add emoji edit button");
const footerCheck = setInterval(() => {
const footer = document.getElementById('footer');
if (footer) {
clearInterval(footerCheck);
console.debug("Footer found, adding button");
const btn = document.createElement('button');
btn.textContent = 'Edit Enhancer';
btn.style = 'margin-left: 10px; padding: 4px 8px; font-size: 12px;';
btn.onclick = createSettingsUI;
footer.appendChild(btn);
}
}, 1000);
}
// ------------ Custom User Selection Data Enhancement below (nothing to do with actual user polling) ----------
function observeUserContextMenu() {
const observer = new MutationObserver(async (mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (
node.nodeType === 1 &&
node.id === 'context-menu-container'
) {
const menu = node.querySelector('#context-menu');
const userItem = menu?.querySelector('.context-menu-user');
const username = userItem?.textContent?.trim();
if (!username) return;
const data = await getCached(username);
if (!data?.response) return;
const { isFriend, stats, id } = data.response;
const gold = stats?.gold ?? 'N/A';
const profileURL = `https://gazellegames.net/user.php?id=${id}`;
// Wrap existing items in a new container
const existingItems = Array.from(menu.children).filter(child => child.tagName === 'LI' || child.classList.contains('context-menu-divider'));
const leftDiv = document.createElement('div');
leftDiv.style.flex = '1';
existingItems.forEach(item => leftDiv.appendChild(item));
const bg = window.getComputedStyle(menu).backgroundColor;
const rgb = bg.match(/\d+/g).map(Number);
const brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 255000; // Normalize 0-1.
const textColor = brightness < 0.4 ? 'white' : 'black'; // Give slight favor to black (up to 60%)
const rightDiv = document.createElement('div');
rightDiv.style.flex = '1';
rightDiv.style.fontSize = '0.85em';
rightDiv.style.color = textColor; // poor man's darkmode awareness applied
rightDiv.style.paddingLeft = '10px';
rightDiv.style.userSelect = 'text'; // allow text selection. Without this cannot highlight.
rightDiv.style.pointerEvents = 'auto';
rightDiv.addEventListener('contextmenu', (e) => { // dirty hack to allow right clicking on the right side.
e.stopPropagation();
});
const selectedFields = getSelectedFields();
const rows = [];
function addRow(label, value) {
if (value !== undefined && value !== null)
rows.push(`<div><strong>${label}:</strong> ${value}</div>`);
}
// Conditionally render fields
if (selectedFields.includes('isFriend')) addRow('Friend', isFriend ? '✅' : '❌');
if (selectedFields.includes('gold')) addRow('Gold', stats.gold);
if (selectedFields.includes('profile')) {
rows.push(`<div><a href="${profileURL}" target="_blank" style="color: #4ea1d3;">Profile</a></div>`);
}
if (selectedFields.includes('joinedDate')) addRow('Joined', formatDate(stats.joinedDate));
if (selectedFields.includes('uploaded')) addRow('Uploaded', formatBytes(stats.uploaded));
if (selectedFields.includes('downloaded')) addRow('Downloaded', formatBytes(stats.downloaded));
if (selectedFields.includes('fullDownloaded')) addRow('Full Downloaded', formatBytes(stats.fullDownloaded));
if (selectedFields.includes('ratio')) addRow('Ratio', stats.ratio);
if (selectedFields.includes('shareScore')) addRow('Share Score', stats.shareScore);
const p = data.response.personal;
if (selectedFields.includes('class')) addRow('Class', p.class);
if (selectedFields.includes('donor')) addRow('Donor', p.donor ? '✅' : '❌');
if (selectedFields.includes('warned')) addRow('Warned', p.warned ? '⚠️' : 'No');
if (selectedFields.includes('paranoia')) addRow('Paranoia', p.paranoiaText);
const c = data.response.community;
if (selectedFields.includes('torrentsUploaded')) addRow('Torrents Uploaded', c.uploaded);
if (selectedFields.includes('hourlyGold')) addRow('Hourly Gold', c.hourlyGold);
if (selectedFields.includes('actualPosts')) addRow('Forum Posts', c.actualPosts);
if (selectedFields.includes('threads')) addRow('Threads', c.threads);
if (selectedFields.includes('forumLikes')) addRow('Likes', c.forumLikes);
if (selectedFields.includes('forumDislikes')) addRow('Dislikes', c.forumDislikes);
if (selectedFields.includes('ircActualLines')) addRow('IRC Lines', c.ircActualLines);
if (selectedFields.includes('seedSize')) addRow('Seed Size', formatBytes(c.seedSize));
rightDiv.innerHTML = rows.join('');
// Clear menu and insert flex container
menu.innerHTML = '';
menu.style.display = 'flex';
menu.style.flexDirection = 'row';
menu.style.minWidth = '500px';
menu.appendChild(leftDiv);
menu.appendChild(rightDiv);
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
function formatBytes(bytes) {
if (bytes === null) {
return null;
}
const MB = 1024 ** 2;
const GB = 1024 ** 3;
const TB = 1024 ** 4;
const PB = 1024 ** 5;
if (bytes >= PB) {
return (bytes / PB).toFixed(3) + ' PB';
} else if (bytes >= TB) {
return (bytes / TB).toFixed(3) + ' TB';
} else if (bytes >= GB) {
return (bytes / GB).toFixed(3) + ' GB';
} else {
return (bytes / MB).toFixed(3) + ' MB';
}
}
function formatDate(dateStr) {
return dateStr.split(' ')[0]; // YYYY-MM-DD
}
function getSelectedFields() {
return JSON.parse(localStorage.getItem('selectedUserFields') || '[]');
}
})();