GGn TheLounge User Data Enhancement

Append GGn class emoji to usernames with a customizable UI

// ==UserScript==
// @name         GGn TheLounge User Data Enhancement
// @version      3.1.2
// @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": "❓",

        // Below here are not actual user classes but I am too lazy to rewrite it all for this.
        "prepend_warn": "⚠️",
        "prepend_disabled": "🚫",
        "prepend_friend": "❤️",
        "low_gold": "⬇️💰",
        "negative_gold": "❗💰",
    };

    let classToEmoji = {};

    async function loadClassMap() {
        console.debug("Loading class emoji map...");
        const saved = await GM.getValue("customClassMap");
        const savedMap = saved ? JSON.parse(saved) : {};

        // Build classToEmoji using only keys from defaultClassToEmoji. Allows for default on new keys and removing old keys naturally.
        classToEmoji = {};
        for (const key of Object.keys(defaultClassToEmoji)) {
            classToEmoji[key] = key in savedMap ? savedMap[key] : defaultClassToEmoji[key];
        }

        console.debug("Loaded classToEmoji:", classToEmoji);
    }

    const defaultEmojiVisibility = {
        class: true,
        warn: true,
        disabled: true,
        friend: true,
        lowGold: false,
        lowGoldLowClass: true,
        negativeGold: true,
    };
    let emojiVisibility = {};


    async function loadEmojiVisibility() {
        console.debug("Loading emoji visibility settings...");
        const saved = await GM.getValue("emojiVisibilitySettings");
        const savedMap = saved ? JSON.parse(saved) : {};

        emojiVisibility = {};
        for (const key of Object.keys(defaultEmojiVisibility)) {
            emojiVisibility[key] = key in savedMap ? savedMap[key] : defaultEmojiVisibility[key];
        }

        console.debug("Loaded emojiVisibility:", emojiVisibility);
    }



    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.debug("Response data:")
                        console.debug(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 isWarned = personal?.warned;
        const isDisabled = !personal?.enabled;
        const isFriend = data?.response?.isFriend;
        console.debug(username, isFriend);

        const emoji = classToEmoji[userClass];
        const warnEmoji = classToEmoji["prepend_warn"] ?? "";
        const disabledEmoji = classToEmoji["prepend_disabled"] ?? "";
        const friendEmoji = classToEmoji["prepend_friend"] ?? "";
        const lowGoldEmoji = classToEmoji["low_gold"] ?? "";
        const negativeGoldEmoji = classToEmoji["negative_gold"] ?? "";

        const lowClass = ["Amateur", "Gamer", "Pro Gamer"].includes(userClass);
        const goldValue = data?.response?.stats?.gold;
        let goldEmoji = '';

        if (goldValue !== undefined && goldValue !== null) {
            const isNegative = emojiVisibility.negativeGold && goldValue < 0;
            const isLowGold =
                (emojiVisibility.lowGoldLowClass && lowClass && goldValue < 10000) ||
                (emojiVisibility.lowGold && goldValue < 10000);


            if (isNegative) {
                goldEmoji += negativeGoldEmoji;
            } else if (isLowGold) {
                goldEmoji += lowGoldEmoji;
            }
        }

        const prependEmoji =
            (emojiVisibility.warn && isWarned ? warnEmoji : '') +
            (emojiVisibility.disabled && isDisabled ? disabledEmoji : '') +
            (emojiVisibility.friend && isFriend ? friendEmoji : '') +
            goldEmoji;

        userEl.dataset.emojiAppended = "true";

        // Clear old emoji spans if re-rendering
        userEl.querySelectorAll('.inline-emoji').forEach(e => e.remove());

        // Prepend span
        if (prependEmoji.trim()) {
            const pre = document.createElement('span');
            pre.className = 'inline-emoji';
            pre.textContent = prependEmoji;
            pre.style.marginRight = '4px';
            pre.style.pointerEvents = 'none';
            pre.style.userSelect = 'none';
            userEl.insertBefore(pre, userEl.firstChild);
        }

        if (emojiVisibility.class && emoji) {
            const post = document.createElement('span');
            post.className = 'inline-emoji';
            post.textContent = emoji;
            post.style.marginLeft = '4px';
            post.style.pointerEvents = 'none';
            post.style.userSelect = 'none';
            userEl.appendChild(post);
        }
    }



    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');
        document.head.insertAdjacentHTML('beforeend', `
            <style>
            .inline-emoji {
            font-size: inherit;
            vertical-align: middle;
            }
            </style>
        `);
        document.head.appendChild(style);
        new MutationObserver(scanRecentMessages).observe(msgContainer, { childList: true, subtree: true });
        injectCSS();
        scanRecentMessages();
    }

    function injectCSS() {
        const css = `
		#chat .from {
			width: 190px !important;
		}
		.inline-emoji {
			font-size: inherit;
			vertical-align: middle;
		}
	`;
        const style = document.createElement('style');
        style.textContent = css;
        document.head.appendChild(style);
    }

    (async () => {
        console.debug("Running main async block");
        await loadClassMap();
        await loadEmojiVisibility();
        const appCheck = setInterval(() => {
            const app = document.getElementById('app');
            if (app) {
                clearInterval(appCheck);
                console.debug("App container found, initializing UI");
                addScriptSettingsButton();
                init();
                openUserContextMenu();
            }
        }, 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() {
        let currentRenderFn = null;
        console.debug("Creating routed settings UI");

        const modal = document.createElement('div');
        modal.style = `
        position: fixed; top: 50%; left: 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;
        transform: translate(-50%, -50%);
    `;
        modal.setAttribute("id", "enhancementSettingsModal");

        const header = document.createElement('div');
        header.style = `
        background-color: #eee;
        padding: 10px;
        font-weight: bold;
        position: relative;
        border-bottom: 1px solid #ccc;
        display: flex;
        align-items: center;
        justify-content: space-between;
    `;

        const titleContainer = document.createElement('div');
        titleContainer.style = 'display: flex; align-items: center; gap: 10px;';

        const backBtn = document.createElement('span');
        backBtn.innerHTML = '←';
        backBtn.style = `cursor: pointer; font-size: 18px; display: none;`;

        const title = document.createElement('span');
        title.textContent = 'Enhancement Settings';

        titleContainer.appendChild(backBtn);
        titleContainer.appendChild(title);

        const closeBtn = document.createElement('span');
        closeBtn.innerHTML = '&times;';
        closeBtn.style = `color: red; font-size: 18px; font-weight: bold; cursor: pointer;`;
        closeBtn.onclick = () => modal.remove();

        header.appendChild(titleContainer);
        header.appendChild(closeBtn);

        const content = document.createElement('div');
        content.style.padding = '10px';

        modal.appendChild(header);
        modal.appendChild(content);
        document.body.appendChild(modal);

        const routeStack = [];

        function setPage(titleText, renderFn) {
            currentRenderFn = renderFn;
            title.textContent = titleText;
            content.innerHTML = '';
            renderFn(content);
            backBtn.style.display = routeStack.length > 0 ? 'inline' : 'none';
        }

        backBtn.onclick = () => {
            const prev = routeStack.pop();
            if (prev) setPage(prev.title, prev.renderFn);
        };

        function setLandingPage() {
            routeStack.length = 0;
            setPage('Enhancement Settings', (container) => {
                const options = [
                    ['Emojis on Name', () => setSubPage('Emojis on Name', renderEmojiEditor)],
                    ['Profile Visibility', () => setSubPage('Profile Visibility', renderProfileVisibilitySettingsPage)],
                    ['Cached Warnings', () => setSubPage('Users With Warnings', renderWarningList)],
                    ['Emoji Visibility', () => setSubPage('Emoji Visibility', renderEmojiVisibilityPage)],
                ];
                options.forEach(([label, onClick]) => {
                    const btn = document.createElement('button');
                    btn.textContent = label;
                    btn.style = `
                    display: block; width: 100%;
                    margin-bottom: 10px; padding: 10px;
                    font-size: 16px; border-radius: 6px;
                    cursor: pointer; border: 1px solid #ccc;
                    background: #f9f9f9;
                `;
                    btn.onclick = onClick;
                    container.appendChild(btn);
                });
            });
        }

        function setSubPage(titleText, renderFn) {
            routeStack.push({ title: title.textContent, renderFn: currentRenderFn });
            setPage(titleText, renderFn);
        }

        makeElementDraggable(modal, header);
        setLandingPage();
    }

    function renderEmojiVisibilityPage(container) {
        const form = document.createElement('form');
        form.style.maxHeight = '300px';
        form.style.overflowY = 'auto';

        const checkboxes = {};

        const fields = [
            ['class', 'Show Class Emojis'],
            ['warn', 'Show Warn Emoji'],
            ['disabled', 'Show Disabled Emoji'],
            ['friend', 'Show Friend Emoji'],
            ['lowGold', 'Show Low Gold Emoji (All)'],
            ['lowGoldLowClass', 'Show Low Gold Emoji (Power User and Below)'],
            ['negativeGold', 'Show Negative Gold Emoji'],
        ];

        fields.forEach(([key, label]) => {
            const row = document.createElement('label');
            row.style.display = 'block';
            row.style.marginBottom = '6px';

            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.checked = emojiVisibility[key];
            checkboxes[key] = checkbox;

            row.appendChild(checkbox);
            row.append(` ${label}`);
            form.appendChild(row);
        });

        const saveBtn = document.createElement('button');
        saveBtn.textContent = 'Save';
        saveBtn.style = `margin-top: 10px; margin-right: 10px; padding: 8px 12px; background-color: #4CAF50; color: white; border: none; border-radius: 5px;`;
        saveBtn.onclick = async (e) => {
            e.preventDefault();
            for (const key in checkboxes) {
                emojiVisibility[key] = checkboxes[key].checked;
            }
            await GM.setValue("emojiVisibilitySettings", JSON.stringify(emojiVisibility));
            document.querySelector('#enhancementSettingsModal span').click();
        };

        const resetBtn = document.createElement('button');
        resetBtn.textContent = 'Reset';
        resetBtn.style = `margin-top: 10px; padding: 8px 12px; background-color: #f44336; color: white; border: none; border-radius: 5px;`;
        resetBtn.onclick = async (e) => {
            e.preventDefault();
            for (const key in defaultEmojiVisibility) {
                emojiVisibility[key] = defaultEmojiVisibility[key];
                checkboxes[key].checked = defaultEmojiVisibility[key];
            }
            await GM.setValue("emojiVisibilitySettings", JSON.stringify(emojiVisibility));
        };

        container.appendChild(form);
        container.appendChild(saveBtn);
        container.appendChild(resetBtn);
    }


    function makeElementDraggable(el, handle) {
        let offsetX = 0, offsetY = 0, isDragging = false;

        handle.style.cursor = 'move';

        handle.addEventListener('mousedown', function (e) {
            isDragging = true;

            // Remove transform if still present and switch to absolute positioning. Without this on first click it'll "jump" around
            if (el.style.transform.includes('translate')) {
                const rect = el.getBoundingClientRect();
                el.style.left = `${rect.left}px`;
                el.style.top = `${rect.top}px`;
                el.style.transform = 'none';
            }

            offsetX = e.clientX - el.offsetLeft;
            offsetY = e.clientY - el.offsetTop;

            document.addEventListener('mousemove', moveHandler);
            document.addEventListener('mouseup', upHandler);
            e.preventDefault();
        });

        function moveHandler(e) {
            if (!isDragging) return;
            el.style.left = `${e.clientX - offsetX}px`;
            el.style.top = `${e.clientY - offsetY}px`;
        }

        function upHandler() {
            isDragging = false;
            document.removeEventListener('mousemove', moveHandler);
            document.removeEventListener('mouseup', upHandler);
        }
    }


    function renderEmojiEditor(container) {
        const form = document.createElement('form');
        form.style.maxHeight = '300px';
        form.style.overflowY = 'auto';

        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>
        `;
            form.appendChild(row);
        });

        const saveBtn = document.createElement('button');
        saveBtn.textContent = 'Save';
        saveBtn.style = `margin-top: 10px; margin-right: 10px; padding: 8px 12px; background-color: #4CAF50; color: white; border: none; border-radius: 5px;`;
        saveBtn.onclick = async (e) => {
            e.preventDefault();
            const inputs = form.querySelectorAll('input');
            classToEmoji = {};
            inputs.forEach(input => {
                const cls = input.getAttribute('data-class');
                classToEmoji[cls] = input.value;
            });
            await saveClassMap();
            document.querySelector('#enhancementSettingsModal span').click();
        };

        const resetBtn = document.createElement('button');
        resetBtn.textContent = 'Reset';
        resetBtn.style = `margin-top: 10px; padding: 8px 12px; background-color: #f44336; color: white; border: none; border-radius: 5px;`;
        resetBtn.onclick = async (e) => {
            e.preventDefault();
            if (!confirm('Reset emoji mappings to default?')) return;
            classToEmoji = { ...defaultClassToEmoji };
            await saveClassMap();
            renderEmojiEditor(container);
        };

        container.appendChild(form);
        container.appendChild(saveBtn);
        container.appendChild(resetBtn);
    }

    function renderProfileVisibilitySettingsPage(container) {
        const form = document.createElement('form');
        form.style.maxHeight = '300px';
        form.style.overflowY = 'auto';

        const selected = new Set(getSelectedFields());
        const checkboxes = {};

        const fieldOptions = [
            // top-level
            ['id', 'User ID'],
            ['username', 'Username'],
            ['avatar', 'Avatar'],
            ['avatarType', 'Avatar Type'],
            ['isFriend', 'Friend'],

            // stats
            ['joinedDate', 'Joined'],
            ['lastAccess', 'Last Seen'],
            ['onIRC', 'IRC'],
            ['uploaded', 'Uploaded'],
            ['downloaded', 'Downloaded'],
            ['fullDownloaded', 'Full Downloaded'],
            ['purchasedDownload', 'Purchased Download'],
            ['ratio', 'Ratio'],
            ['requiredRatio', 'Required Ratio'],
            ['shareScore', 'Share Score'],
            ['gold', 'Gold'],

            // personal
            ['class', 'Class'],
            ['facilitator', 'Facilitator'],
            ['hnrs', 'HNRS'],
            ['donor', 'Donor'],
            ['warned', 'Warned'],
            ['enabled', 'Enabled'],
            ['publicKey', 'Public Key'],
            ['parked', 'Parked'],
            ['paranoiaText', 'Paranoia'],

            // community
            ['clan', 'Clan'],
            ['profileViews', 'Profile Views'],
            ['hourlyGold', 'Hourly Gold'],
            ['posts', 'Forum Posts'],
            ['actualPosts', 'Raw Forum Posts'],
            ['threads', 'Threads'],
            ['forumLikes', 'Likes'],
            ['forumDislikes', 'Dislikes'],
            ['ircLines', 'IRC Lines'],
            ['ircActualLines', 'Raw IRC Lines'],
            ['torrentComments', 'Torrent Comments'],
            ['collections', 'Collections'],
            ['requestsFilled', 'Requests Filled'],
            ['bountyEarnedUpload', 'Bounty Earned (Upload)'],
            ['bountyEarnedGold', 'Bounty Earned (Gold)'],
            ['requestsVoted', 'Requests Voted'],
            ['bountySpentUpload', 'Bounty Spent (Upload)'],
            ['bountySpentGold', 'Bounty Spent (Gold)'],
            ['reviews', 'Reviews'],
            ['seeding', 'Seeding'],
            ['leeching', 'Leeching'],
            ['snatched', 'Snatched'],
            ['uniqueSnatched', 'Unique Snatched'],
            ['seedSize', 'Seed Size'],
            ['invited', 'Invited users'],

            // buffs
            ['buffUpload', 'Buff: Upload'],
            ['buffDownload', 'Buff: Download'],
            ['buffForumPosts', 'Buff: Forum Posts'],
            ['buffIRCLines', 'Buff: IRC Lines'],
            ['buffIRCBonus', 'Buff: IRC Bonus'],
            ['buffCommunityXP', 'Buff: Community XP'],
            ['buffTorrentsXP', 'Buff: Torrents XP'],
            ['buffCommunityGold', 'Buff: Community Gold'],
            ['buffTorrentsGold', 'Buff: Torrents Gold'],
            ['buffItemCost', 'Buff: Item Cost'],
            ['buffBountyFrom', 'Buff: Bounty From'],
            ['buffBountyOn', 'Buff: Bounty On'],
            ['buffChance', 'Buff: Chance']
        ];



        fieldOptions.forEach(([key, label]) => {
            const row = document.createElement('label');
            row.style.display = 'block';
            row.style.marginBottom = '6px';

            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.checked = selected.has(key);
            checkboxes[key] = checkbox;

            checkbox.addEventListener('change', () => {
                if (checkbox.checked) selected.add(key);
                else selected.delete(key);
                setSelectedFields(Array.from(selected));
            });

            row.appendChild(checkbox);
            row.append(' ' + label);
            form.appendChild(row);
        });

        const saveBtn = document.createElement('button');
        saveBtn.textContent = 'Save';
        saveBtn.style = `margin-top: 10px; margin-right: 10px; padding: 8px 12px; background-color: #4CAF50; color: white; border: none; border-radius: 5px;`;
        saveBtn.onclick = (e) => {
            e.preventDefault();
            // just close via back button
            document.querySelector('#enhancementSettingsModal span').click();
        };

        const resetBtn = document.createElement('button');
        resetBtn.textContent = 'Reset';
        resetBtn.style = `margin-top: 10px; padding: 8px 12px; background-color: #f44336; color: white; border: none; border-radius: 5px;`;
        resetBtn.onclick = (e) => {
            e.preventDefault();
            const defaults = new Set(JSON.parse(defaultProfileVisibilitySelectedFiels));

            // Uncheck everything, then re-check only defaults
            Object.entries(checkboxes).forEach(([key, chk]) => {
                const shouldBeChecked = defaults.has(key);
                chk.checked = shouldBeChecked;

                if (shouldBeChecked) selected.add(key);
                else selected.delete(key);
            });

            setSelectedFields(Array.from(selected));
        };

        container.appendChild(form);
        container.appendChild(saveBtn);
        container.appendChild(resetBtn);
    }


    async function renderWarningList(container) {
        const form = document.createElement('form');
        const keys = await GM.listValues();
        const warningUsers = [];

        const now = Date.now();

        for (const key of keys) {
            if (key.startsWith('userCache_')) {
                const raw = await GM.getValue(key);
                if (!raw) continue;

                let parsed;
                try {
                    parsed = JSON.parse(raw);
                } catch {
                    continue; // skip malformed cache
                }

                const { timestamp, data } = parsed;
                const expired = (now - timestamp) > CACHE_DURATION_MS;
                if (expired || !data?.response?.personal?.warned) continue;

                const username = key.replace('userCache_', '');
                warningUsers.push(username);
            }
        }

        if (warningUsers.length === 0) {
            container.innerHTML = `<p>No users with valid, cached warnings.</p>`;
            return;
        }

        const ul = document.createElement('ul');
        warningUsers.forEach(user => {
            const li = document.createElement('li');
            li.textContent = user;
            ul.appendChild(li);
        });

        ul.style.maxHeight = '300px';
        ul.style.overflowY = 'auto';
        ul.style.paddingLeft = '20px';

        container.appendChild(ul);
    }

    function addScriptSettingsButton() {
        console.debug("Attempting to add settings 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 openUserContextMenu() {
        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 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 rows = [];

                        function addRow(label, value) {
                            if (value !== undefined && value !== null && !/^\s*$/.test(value)) // regex checks for only whitespace
                                rows.push(`<div><strong>${label}:</strong> ${value}</div>`);
                        }

                        const s = data.response.stats || {};
                        const p = data.response.personal || {};
                        const c = data.response.community || {};
                        const b = data.response.buffs || {};
                        const top = data.response || {};
                        const selectedFields = getSelectedFields();

                        if (selectedFields.includes('avatar')) {
                            const avatarItem = document.createElement('li');
                            avatarItem.className = 'context-menu-item';
                            avatarItem.role = 'menuitem';
                            avatarItem.innerHTML = `<img src="${top.avatar}" style="max-height:80px; vertical-align: middle;">`;
                            leftDiv.insertBefore(avatarItem, leftDiv.children[1]); // Insert right after the username
                        }

                        if (selectedFields.includes('id')) addRow('User ID', top.id);
                        if (selectedFields.includes('username')) addRow('Username', top.username);
                        if (selectedFields.includes('avatarType')) addRow('Avatar Type', top.avatarType);
                        if (selectedFields.includes('profile')) {
                            rows.push(`<div><a href="${profileURL}" target="_blank" style="color: #4ea1d3;">Profile</a></div>`);
                        }
                        if (selectedFields.includes('isFriend')) addRow('Friend', isFriend ? '✅' : '❌');

                        if (selectedFields.includes('joinedDate')) addRow('Joined', formatDate(s.joinedDate));
                        if (selectedFields.includes('lastAccess')) addRow('Last Seen', formatDate(s.lastAccess));
                        if (selectedFields.includes('onIRC')) addRow('IRC', s.onIRC ? '✅' : '❌');
                        if (selectedFields.includes('gold')) {
                            let gold = s.gold;
                            if (typeof gold === 'string' && gold.includes('-')) {
                                gold = `<span style="color:red; font-weight:bold;">${gold}</span>`;
                            }
                            addRow('Gold', gold);
                        }
                        if (selectedFields.includes('uploaded')) addRow('Uploaded', formatBytes(s.uploaded));
                        if (selectedFields.includes('downloaded')) addRow('Downloaded', formatBytes(s.downloaded));
                        if (selectedFields.includes('fullDownloaded')) addRow('Full Downloaded', formatBytes(s.fullDownloaded));
                        if (selectedFields.includes('purchasedDownload')) addRow('Purchased Download', formatBytes(s.purchasedDownload));
                        if (selectedFields.includes('ratio')) addRow('Ratio', s.ratio);
                        if (selectedFields.includes('requiredRatio')) addRow('Required Ratio', s.requiredRatio);
                        if (selectedFields.includes('shareScore')) addRow('Share Score', s.shareScore);

                        if (selectedFields.includes('class')) addRow('Class', p.class);
                        if (selectedFields.includes('facilitator')) addRow('Facilitator', p.facilitator ? '✅' : '❌');
                        if (selectedFields.includes('hnrs')) addRow('HNRS', p.hnrs);
                        if (selectedFields.includes('donor')) addRow('Donor', p.donor ? '✅' : '❌');
                        if (selectedFields.includes('warned')) addRow('Warned', p.warned ? '⚠️' : 'No');
                        if (selectedFields.includes('enabled')) addRow('Enabled', p.enabled ? '✅' : '❌');
                        if (selectedFields.includes('publicKey')) addRow('Public Key', p.publicKey);
                        if (selectedFields.includes('parked')) addRow('Parked', p.parked ? '✅' : '❌');
                        if (selectedFields.includes('paranoiaText')) addRow('Paranoia', p.paranoiaText);

                        if (selectedFields.includes('clan')) addRow('Clan', c.clan);
                        if (selectedFields.includes('profileViews')) addRow('Profile Views', c.profileViews);
                        if (selectedFields.includes('hourlyGold')) addRow('Hourly Gold', c.hourlyGold);
                        if (selectedFields.includes('posts')) addRow('Forum Posts', c.posts);
                        if (selectedFields.includes('actualPosts')) addRow('Raw 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('ircLines')) addRow('IRC Lines', c.ircLines);
                        if (selectedFields.includes('ircActualLines')) addRow('Raw IRC Lines', c.ircActualLines);
                        if (selectedFields.includes('torrentComments')) addRow('Torrent Comments', c.torrentComments);
                        if (selectedFields.includes('collections')) addRow('Collections', c.collections);
                        if (selectedFields.includes('requestsFilled')) addRow('Requests Filled', c.requestsFilled);
                        if (selectedFields.includes('bountyEarnedUpload')) addRow('Bounty Earned (Upload)', c.bountyEarnedUpload);
                        if (selectedFields.includes('bountyEarnedGold')) addRow('Bounty Earned (Gold)', c.bountyEarnedGold);
                        if (selectedFields.includes('requestsVoted')) addRow('Requests Voted', c.requestsVoted);
                        if (selectedFields.includes('bountySpentUpload')) addRow('Bounty Spent (Upload)', c.bountySpentUpload);
                        if (selectedFields.includes('bountySpentGold')) addRow('Bounty Spent (Gold)', c.bountySpentGold);
                        if (selectedFields.includes('reviews')) addRow('Reviews', c.reviews);
                        if (selectedFields.includes('seeding')) addRow('Seeding', c.seeding);
                        if (selectedFields.includes('leeching')) addRow('Leeching', c.leeching);
                        if (selectedFields.includes('snatched')) addRow('Snatched', c.snatched);
                        if (selectedFields.includes('uniqueSnatched')) addRow('Unique Snatched', c.uniqueSnatched);
                        if (selectedFields.includes('seedSize')) addRow('Seed Size', formatBytes(c.seedSize));
                        if (selectedFields.includes('invited')) addRow('Invited Users', c.invited);

                        if (selectedFields.includes('buffUpload')) addRow('Buff: Upload', b.Upload);
                        if (selectedFields.includes('buffDownload')) addRow('Buff: Download', b.Download);
                        if (selectedFields.includes('buffForumPosts')) addRow('Buff: Forum Posts', b.ForumPosts);
                        if (selectedFields.includes('buffIRCLines')) addRow('Buff: IRC Lines', b.IRCLines);
                        if (selectedFields.includes('buffIRCBonus')) addRow('IRC Bonus', b.IRCBonus);
                        if (selectedFields.includes('buffCommunityXP')) addRow('Community XP', b.CommunityXP);
                        if (selectedFields.includes('buffTorrentsXP')) addRow('Torrents XP', b.TorrentsXP);
                        if (selectedFields.includes('buffCommunityGold')) addRow('Community Gold', b.CommunityGold);
                        if (selectedFields.includes('buffTorrentsGold')) addRow('Torrents Gold', b.TorrentsGold);
                        if (selectedFields.includes('buffItemCost')) addRow('Shop Discount', b.ItemCost);
                        if (selectedFields.includes('buffBountyFrom')) addRow('Bounty From', b.BountyFrom);
                        if (selectedFields.includes('buffBountyOn')) addRow('Bounty On', b.BountyOn);
                        if (selectedFields.includes('buffChance')) addRow('Chance', b.Chance);

                        const MAX_ROWS_PER_COLUMN = 10;

                        const rightWrapper = document.createElement('div');
                        rightWrapper.style.display = 'flex';
                        rightWrapper.style.gap = '10px';
                        rightWrapper.style.width = 'fit-content';
                        rightWrapper.style.flex = '0 0 auto';
                        rightWrapper.style.userSelect = 'text';
                        rightWrapper.style.pointerEvents = 'auto';
                        rightWrapper.style.flexWrap = 'nowrap';    // Prevent flex items from wrapping to new lines
                        rightWrapper.addEventListener('contextmenu', e => e.stopPropagation());

                        // Group rows into columns
                        for (let i = 0; i < rows.length; i += MAX_ROWS_PER_COLUMN) {
                            const col = document.createElement('div');
                            col.style.flex = '1';
                            col.style.fontSize = '0.85em';
                            col.style.whiteSpace = 'nowrap';
                            col.style.color = textColor;

                            const columnRows = rows.slice(i, i + MAX_ROWS_PER_COLUMN).join('');
                            col.innerHTML = columnRows;
                            rightWrapper.appendChild(col);
                        }

                        // Clear menu and insert flex container
                        menu.innerHTML = '';
                        menu.style.display = 'flex';
                        menu.style.flexDirection = 'row';
                        menu.style.minWidth = '250px';

                        menu.appendChild(leftDiv);
                        menu.appendChild(rightWrapper);
                    }
                }
            }
        });

        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
    }

    const defaultProfileVisibilitySelectedFiels = `[
            "avatar",
            "isFriend",
            "gold",
            "profile",
            "joinedDate",
            "uploaded",
            "downloaded",
            "ratio",
            "shareScore",
            "class",
            "donor",
            "warned",
            "torrentsUploaded",
            "paranoia",
            "hourlyGold",
            "posts",
            "threads",
            "forumDislikes",
            "forumLikes",
            "ircLines",
            "seedSize"
        ]`;

    function getSelectedFields() {
        return JSON.parse(localStorage.getItem('selectedUserFields') || defaultProfileVisibilitySelectedFiels);
    }

    function setSelectedFields(fields) {
        try {
            localStorage.setItem('selectedUserFields', JSON.stringify(fields));
        } catch (e) {
            console.error('Failed to save selected fields:', e);
        }
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址