Torn Revive Status Indicator

Shows revive status icons for faction members in Torn

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Torn Revive Status Indicator
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  Shows revive status icons for faction members in Torn
// @author       You
// @match        https://www.torn.com/factions.php*
// @match        https://www.torn.com/profiles.php?XID=*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      api.torn.com
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    const CACHE_DURATION = 2 * 60 * 1000;
    const FACTION_ID = null;
    const MIN_REFRESH_INTERVAL = 2 * 60 * 1000;
    const PAGE_CHANGE_DELAY = 3000;
    const TOAST_DURATION = 2000;

    let API_KEY = GM_getValue("torn_revive_api_key", null);
    let lastApiCallTime = GM_getValue("last_api_call_time", {}) || {};
    let reviveDataCache = GM_getValue("revive_data_cache", { data: {}, timestamp: 0 });

    const reviveIcons = {
        revivable: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" style="fill: #4CAF50;"><circle cx="8" cy="8" r="7" stroke="#2E7D32" stroke-width="1"/><path d="M5 8L7 10L11 6" stroke="#2E7D32" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`,
        notRevivable: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" style="fill: #F44336;"><circle cx="8" cy="8" r="7" stroke="#C62828" stroke-width="1"/><path d="M5 5L11 11M5 11L11 5" stroke="#C62828" stroke-width="2" stroke-linecap="round"/></svg>`,
        unknown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" style="fill: #9E9E9E;"><circle cx="8" cy="8" r="7" stroke="#757575" stroke-width="1"/><text x="8" y="12" text-anchor="middle" font-family="Arial" font-size="10" font-weight="bold" fill="#757575">?</text></svg>`
    };

    GM_registerMenuCommand("Set Torn API Key", () => {
        let userInput = prompt("Enter Public API Key:", API_KEY || "");
        if (userInput !== null) {
            API_KEY = userInput;
            GM_setValue("torn_revive_api_key", API_KEY);
            showToast("API key saved", "success", true);
        }
    });

    function showToast(message, type = "info", important = false) {
        if (!important) {
            console.log(`[Revive Status] ${message}`);
            return;
        }

        const colors = { success: "#4CAF50", error: "#F44336", info: "#2196F3", warning: "#FF9800" };
        document.querySelectorAll('.revive-toast').forEach(toast => toast.remove());

        const toast = document.createElement("div");
        toast.className = "revive-toast";
        toast.textContent = message;
        toast.style.cssText = `
            position: fixed;
            top: 20px;
            right: 20px;
            background: ${colors[type] || colors.info};
            color: white;
            padding: 10px 16px;
            border-radius: 4px;
            z-index: 10000;
            font-family: Arial, sans-serif;
            font-size: 13px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.2);
            animation: reviveToastSlideIn 0.3s ease-out;
        `;

        document.body.appendChild(toast);

        setTimeout(() => {
            if (toast.parentNode) {
                toast.style.animation = "reviveToastFadeOut 0.3s ease-out";
                setTimeout(() => toast.remove(), 300);
            }
        }, TOAST_DURATION);
    }

    function isCacheValid() {
        return Date.now() - reviveDataCache.timestamp < CACHE_DURATION;
    }

    function shouldMakeApiCall(factionId) {
        const now = Date.now();
        const lastCall = lastApiCallTime[factionId] || 0;

        if (now - lastCall < MIN_REFRESH_INTERVAL) {
            console.log(`[Revive Status] Skipping API call - too soon (${Math.round((now - lastCall)/1000)}s ago)`);
            return false;
        }

        if (isCacheValid() && reviveDataCache.data[factionId]) {
            const cacheAge = Math.round((now - reviveDataCache.timestamp) / 60000);
            console.log(`[Revive Status] Using cache (${cacheAge} min old)`);
            return false;
        }

        return true;
    }

    async function fetchFactionData(factionId) {
        if (!API_KEY) {
            showToast("Set API key in Tampermonkey menu", "error", true);
            return null;
        }

        if (!shouldMakeApiCall(factionId)) {
            return reviveDataCache.data[factionId] || null;
        }

        try {
            const url = `https://api.torn.com/v2/faction/${factionId}/members?striptags=true&key=${API_KEY}`;
            console.log(`[Revive Status] Making API call for faction ${factionId}`);

            const response = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    onload: resolve,
                    onerror: reject,
                    timeout: 10000
                });
            });

            lastApiCallTime[factionId] = Date.now();
            GM_setValue("last_api_call_time", lastApiCallTime);

            if (response.status === 200) {
                const data = JSON.parse(response.responseText);
                if (data.error) {
                    console.error(`[Revive Status] API Error: ${data.error.error}`);
                    return null;
                }

                const reviveMap = {};
                if (data.members && Array.isArray(data.members)) {
                    data.members.forEach(member => {
                        reviveMap[member.id] = {
                            is_revivable: member.is_revivable,
                            name: member.name,
                            position: member.position
                        };
                    });
                }

                reviveDataCache.data[factionId] = reviveMap;
                reviveDataCache.timestamp = Date.now();
                GM_setValue("revive_data_cache", reviveDataCache);

                console.log(`[Revive Status] Updated cache for ${Object.keys(reviveMap).length} members`);
                return reviveMap;
            } else {
                console.error(`[Revive Status] API request failed: ${response.status}`);
                return null;
            }
        } catch (error) {
            console.error("[Revive Status] Error fetching faction data:", error);
            return null;
        }
    }

    function getFactionId() {
        if (FACTION_ID) return FACTION_ID;

        const url = window.location.href;
        const urlParams = new URLSearchParams(window.location.search);
        const step = urlParams.get("step");
        const idParam = urlParams.get("ID");

        if (step === "profile" && idParam) {
            return idParam;
        }

        if (url.includes("factions.php")) {
            const patterns = [
                /factions\.php.*[?&]ID=(\d+)/i,
                /factions\.php.*[?&]id=(\d+)/i,
                /factions\.php.*[?&]XID=(\d+)/i
            ];

            for (const pattern of patterns) {
                const match = url.match(pattern);
                if (match) return match[1];
            }

            const factionElements = [
                'a[href*="factions.php?step=profile&ID="]',
                '.faction-info [href*="ID="]',
                '.members-list a[href*="ID="]',
                'meta[content*="faction"]'
            ];

            for (const selector of factionElements) {
                const element = document.querySelector(selector);
                if (element) {
                    const href = element.href || element.getAttribute('content') || '';
                    const match = href.match(/ID=(\d+)/);
                    if (match) return match[1];
                }
            }
        }

        if (url.includes("profiles.php")) {
            const factionLink = document.querySelector('a[href*="factions.php?step=profile&ID="]');
            if (factionLink) {
                const match = factionLink.href.match(/ID=(\d+)/);
                if (match) return match[1];
            }
        }

        console.warn("[Revive Status] Could not determine faction ID");
        return null;
    }

    function addReviveIcon(row, memberId, reviveStatus) {
        const existingIcon = row.querySelector('.revive-status-icon');
        if (existingIcon) existingIcon.remove();

        const iconContainer = document.createElement('div');
        iconContainer.className = 'revive-status-icon';
        iconContainer.style.cssText = `display: inline-flex; align-items: center; margin-left: 8px; cursor: help;`;

        let icon, tooltip;
        if (reviveStatus === true) {
            icon = reviveIcons.revivable;
            tooltip = "Revivable";
        } else if (reviveStatus === false) {
            icon = reviveIcons.notRevivable;
            tooltip = "Not Revivable";
        } else {
            icon = reviveIcons.unknown;
            tooltip = "Status unknown";
        }

        iconContainer.innerHTML = icon;
        iconContainer.title = tooltip;

        const statusCell = row.querySelector('.status');
        const daysCell = row.querySelector('.days');
        const positionCell = row.querySelector('.position');

        if (statusCell) {
            statusCell.appendChild(iconContainer);
        } else if (daysCell) {
            daysCell.appendChild(iconContainer);
        } else if (positionCell) {
            positionCell.appendChild(iconContainer);
        } else {
            row.appendChild(iconContainer);
        }

        row.setAttribute('data-revivable', reviveStatus);
    }

    function waitForMemberFilter() {
        return new Promise((resolve) => {
            const existingFilter = document.querySelector('#memberFilter .revive__section-class');
            if (existingFilter) {
                resolve(existingFilter);
                return;
            }

            const observer = new MutationObserver((mutations, obs) => {
                const memberFilter = document.querySelector('#memberFilter');
                if (memberFilter) {
                    obs.disconnect();
                    resolve(memberFilter);
                }
            });

            observer.observe(document.body, { childList: true, subtree: true });
            setTimeout(() => { observer.disconnect(); resolve(null); }, 10000);
        });
    }

    async function addReviveFilterToMemberFilter() {
        const memberFilter = await waitForMemberFilter();
        if (!memberFilter) {
            return;
        }

        if (memberFilter.querySelector('.revive__section-class')) {
            return;
        }

        const content = memberFilter.querySelector('.content');
        if (!content) {
            return;
        }

        const reviveSection = document.createElement('div');
        reviveSection.className = 'revive__section-class';
        reviveSection.innerHTML = `
            <strong>Revive Status</strong>
            <div class="tt-checkbox-list-wrapper tt-checkbox-list-column">
                <div class="tt-checkbox-wrapper">
                    <label>
                        <input type="checkbox" id="revivable-only" class="revive-filter-checkbox">
                        Hide non-revivable
                    </label>
                </div>
            </div>
            <div class="revive-refresh-wrapper" style="margin-top: 8px;">
                <button class="revive-refresh-btn torn-btn btn-small">
                    <i class="fas fa-sync-alt" style="margin-right: 4px;"></i> Refresh Revive Status
                </button>
                <div class="revive-cache-info" style="font-size: 11px; color: #666; margin-top: 4px;"></div>
            </div>
        `;

        const lastSection = content.querySelector('.levelFilter__section-class') ||
                           content.querySelector('.status__section-class') ||
                           content.lastElementChild;

        if (lastSection) {
            content.insertBefore(reviveSection, lastSection.nextSibling);
        } else {
            content.appendChild(reviveSection);
        }

        const checkbox = reviveSection.querySelector('.revive-filter-checkbox');
        if (checkbox) {
            checkbox.checked = GM_getValue("hide_non_revivable", false);
            checkbox.addEventListener('change', function() {
                const hideNonRevivable = this.checked;
                GM_setValue("hide_non_revivable", hideNonRevivable);
                filterNonRevivableMembers(hideNonRevivable);
                showToast(hideNonRevivable ? "Hiding non-revivable members" : "Showing all members", "info", true);
            });
        }

        const refreshBtn = reviveSection.querySelector('.revive-refresh-btn');
        if (refreshBtn) {
            refreshBtn.addEventListener('click', async () => {
                await handleRefreshClick();
            });
        }

        updateCacheInfo(reviveSection);

        const hideNonRevivable = GM_getValue("hide_non_revivable", false);
        if (hideNonRevivable) {
            filterNonRevivableMembers(true);
        }
    }

    function updateCacheInfo(container) {
        const cacheInfo = container.querySelector('.revive-cache-info');
        if (!cacheInfo) return;

        const factionId = getFactionId();
        if (!factionId || !reviveDataCache.data[factionId]) {
            cacheInfo.textContent = "No cache data";
            return;
        }

        const now = Date.now();
        const cacheAge = Math.round((now - reviveDataCache.timestamp) / 1000);
        const memberCount = Object.keys(reviveDataCache.data[factionId]).length;

        if (cacheAge < 60) {
            cacheInfo.textContent = `Cached ${cacheAge}s ago (${memberCount} members)`;
        } else if (cacheAge < 3600) {
            const minutes = Math.floor(cacheAge / 60);
            cacheInfo.textContent = `Cached ${minutes}m ago (${memberCount} members)`;
        } else {
            const hours = Math.floor(cacheAge / 3600);
            cacheInfo.textContent = `Cached ${hours}h ago (${memberCount} members)`;
        }
    }

    async function handleRefreshClick() {
        const factionId = getFactionId();
        const now = Date.now();
        const lastCall = lastApiCallTime[factionId] || 0;

        if (now - lastCall < MIN_REFRESH_INTERVAL) {
            const secondsLeft = Math.ceil((MIN_REFRESH_INTERVAL - (now - lastCall)) / 1000);
            showToast(`Please wait ${secondsLeft} seconds before refreshing again`, "warning", true);
            return;
        }

        if (reviveDataCache.data[factionId]) {
            delete reviveDataCache.data[factionId];
            GM_setValue("revive_data_cache", reviveDataCache);
        }

        await processMemberRows();

        document.querySelectorAll('.revive__section-class').forEach(container => {
            updateCacheInfo(container);
        });
    }

    function filterNonRevivableMembers(hide) {
        const memberRows = document.querySelectorAll('.table-row, .members-list li, .faction-member-row');
        let hiddenCount = 0;

        memberRows.forEach(row => {
            const isRevivable = row.getAttribute('data-revivable');
            if (hide && isRevivable === 'false') {
                if (row.style.display !== 'none') {
                    row.style.display = 'none';
                    hiddenCount++;
                }
            } else {
                row.style.display = '';
            }
        });

        updateStatistics(hiddenCount, hide);

        if (hide && hiddenCount > 0) {
            console.log(`[Revive Status] Hid ${hiddenCount} non-revivable members`);
        }
    }

    function updateStatistics(hiddenCount, isFiltering) {
        const statCount = document.querySelector('.stat-count');
        const statTotal = document.querySelector('.stat-total');

        if (statCount && statTotal) {
            const total = parseInt(statTotal.textContent) || 0;
            if (isFiltering && hiddenCount > 0) {
                const showing = total - hiddenCount;
                statCount.textContent = showing;
            } else {
                statCount.textContent = total;
            }
        }
    }

    async function processMemberRows() {
        const factionId = getFactionId();
        if (!factionId) {
            console.log("[Revive Status] No faction ID found");
            return false;
        }

        const reviveData = await fetchFactionData(factionId);
        if (!reviveData) return false;

        const memberRows = document.querySelectorAll('.table-row, .members-list li, .faction-member-row');
        let processedCount = 0;

        memberRows.forEach(row => {
            let memberId = null;
            const profileLink = row.querySelector('a[href*="/profiles.php?XID="], a[href*="profiles.php?XID="]');
            if (profileLink) {
                const match = profileLink.href.match(/XID=(\d+)/);
                if (match) memberId = match[1];
            }

            if (!memberId) {
                const userIdAttr = row.getAttribute('data-user-id') || row.getAttribute('data-player-id');
                if (userIdAttr) memberId = userIdAttr;
            }

            if (memberId && reviveData[memberId] !== undefined) {
                addReviveIcon(row, memberId, reviveData[memberId].is_revivable);
                processedCount++;
            }
        });

        await addReviveFilterToMemberFilter();
        return processedCount > 0;
    }

    function init() {
        const url = window.location.href;
        const isFactionPage =
            url.includes('factions.php?step=profile') ||
            url.includes('factions.php?step=your') ||
            url.includes('factions.php#/') ||
            (url.includes('factions.php') &&
             (url.includes('ID=') ||
              url.includes('XID=') ||
              document.querySelector('.members-list, .faction-members, #faction-members')));

        if (!isFactionPage) {
            return;
        }

        const factionId = getFactionId();
        if (!factionId) {
            console.log("[Revive Status] Not a faction members page or couldn't detect faction ID");
            return;
        }

        console.log(`[Revive Status] Initializing for faction ID: ${factionId}`);

        const styles = `
            .revive-status-icon { display: inline-flex; align-items: center; margin-left: 8px; vertical-align: middle; }
            .revive-status-icon svg { filter: drop-shadow(0 1px 1px rgba(0,0,0,0.2)); }
            .table-row:hover .revive-status-icon svg { transform: scale(1.1); transition: transform 0.2s; }
            .revive-filter-checkbox { margin: 0; cursor: pointer; }
            .revive-filter-checkbox:checked { accent-color: #4CAF50; }
            .revive-refresh-btn { background: #2196F3; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-family: Arial, sans-serif; font-size: 12px; transition: background 0.2s; }
            .revive-refresh-btn:hover { background: #1976D2; }
            .revive-refresh-btn:disabled { background: #BDBDBD; cursor: not-allowed; }
            @keyframes reviveToastSlideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
            @keyframes reviveToastFadeOut { from { opacity: 1; } to { opacity: 0; } }
        `;

        const styleSheet = document.createElement("style");
        styleSheet.textContent = styles;
        document.head.appendChild(styleSheet);

        setTimeout(async () => {
            const processed = await processMemberRows();
            if (processed) {
                console.log("[Revive Status] Icons added successfully");
            }
        }, PAGE_CHANGE_DELAY);

        let observerTimer;
        const observer = new MutationObserver((mutations) => {
            let shouldProcess = false;

            for (const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    const hasNewRows = Array.from(mutation.addedNodes).some(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            return node.matches('.table-row, .members-list li, .faction-member-row') ||
                                   node.querySelector('.table-row, .members-list li, .faction-member-row');
                        }
                        return false;
                    });

                    if (hasNewRows) {
                        shouldProcess = true;
                        break;
                    }
                }

                if (mutation.type === 'childList') {
                    const hasFilterChange = Array.from(mutation.addedNodes).some(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            return node.matches('#memberFilter') ||
                                   node.querySelector('#memberFilter');
                        }
                        return false;
                    });

                    if (hasFilterChange) {
                        shouldProcess = true;
                        break;
                    }
                }
            }

            if (shouldProcess) {
                clearTimeout(observerTimer);
                observerTimer = setTimeout(async () => {
                    await processMemberRows();
                }, 2000);
            }
        });

        observer.observe(document.body, { childList: true, subtree: true });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();