Torn Virus Timer

Display virus coding timer in the sidebar using Torn API v2

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.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

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 Virus Timer
// @namespace    https://www.torn.com/
// @version      1.2
// @description  Display virus coding timer in the sidebar using Torn API v2
// @author       Woeka [3516612]
// @license      MIT
// @match        https://www.torn.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// ==/UserScript==

(function () {
    'use strict';
    
    function getApiKey() {
        return localStorage.getItem('virus_api_key') || '';
    }

    const timerContainerID = 'virusTimerSidebar';
    const LS_KEY = 'virus_last_info'; // localStorage key

    function getStoredVirusInfo() {
        try {
            return JSON.parse(localStorage.getItem(LS_KEY)) || {};
        } catch {
            return {};
        }
    }

    function setStoredVirusInfo(obj) {
        localStorage.setItem(LS_KEY, JSON.stringify(obj));
    }

    function fetchVirusStatus() {
        const apiKey = getApiKey();
        
        GM_xmlhttpRequest({
            method: "GET",
            url: 'https://api.torn.com/v2/user/virus',
            headers: {
                'accept': 'application/json',
                'Authorization': `ApiKey ${apiKey}`
            },
            onload: function (response) {
                let data;
                try {
                    data = JSON.parse(response.responseText);
                } catch (e) {
                    return;
                }
                
                // Check for API errors
                if (data.error) {
                    if (data.error.code === 1 || data.error.code === 2) {
                        // Invalid key or insufficient permissions
                        updateSidebar("Invalid key", null, true); // true = clickable to edit
                    } else {
                        updateSidebar("API error", null, true); // true = clickable to edit
                    }
                    scheduleNextCheck(3600);
                    return;
                }
                
                const now = Math.floor(Date.now() / 1000);
                
                if (data?.virus && data.virus.until) {
                    const timeLeft = Math.max(0, data.virus.until - now);
                    const virusName = data.virus.item ? data.virus.item.name : 'Virus';
                    setStoredVirusInfo({
                        until: data.virus.until,
                        name: virusName,
                        nextCheck: now + 3600 // check again in 1 hour
                    });
                    updateSidebar(formatTimer(timeLeft), virusName);
                    scheduleNextCheck(3600);
                } else {
                    // No virus being coded
                    setStoredVirusInfo({
                        until: null,
                        name: null,
                        nextCheck: now + 3600
                    });
                    updateSidebar("No virus", null);
                    scheduleNextCheck(3600);
                }
            },
            onerror: function(error) {
                updateSidebar("Network error", null, true); // true = clickable to edit
                // On error, check again in 1 hour
                scheduleNextCheck(3600);
            }
        });
    }

    function formatTimer(secs, showSeconds = false) {
        if (typeof secs !== "number" || isNaN(secs) || secs < 0) return "0d 0h 0m";
        if (showSeconds) {
            const m = Math.floor(secs / 60);
            const s = secs % 60;
            return `${m}:${s.toString().padStart(2, '0')}`;
        }
        const d = Math.floor(secs / 86400);
        const h = Math.floor((secs % 86400) / 3600);
        const m = Math.floor((secs % 3600) / 60);
        let out = "";
        if (d > 0) out += `${d}d `;
        if (h > 0 || d > 0) out += `${h}h `;
        out += `${m}m`;
        return out.trim();
    }

    function showApiKeyPopup(callback) {
        if (document.getElementById('virus_api_input')) return; // Prevent multiple popups
        let popup = document.createElement('div');
        popup.style.position = 'fixed';
        popup.style.top = '50%';
        popup.style.left = '50%';
        popup.style.transform = 'translate(-50%, -50%)';
        popup.style.background = '#222';
        popup.style.color = '#fff';
        popup.style.padding = '20px';
        popup.style.border = '2px solid #888';
        popup.style.zIndex = 9999;
        popup.style.borderRadius = '8px';
        popup.innerHTML = `
            <div style="margin-bottom:10px;">Enter your Torn limited API key:</div>
            <input type="text" id="virus_api_input" style="width:300px;" maxlength="16" value="${getApiKey() || ''}">
            <div style="margin-top:10px;">
                <button id="virus_api_save" style="background:#444;color:#fff;border:1px solid #aaa;padding:6px 18px;margin-right:10px;border-radius:4px;cursor:pointer;">Save</button>
                <button id="virus_api_cancel" style="background:#444;color:#fff;border:1px solid #aaa;padding:6px 18px;border-radius:4px;cursor:pointer;">Cancel</button>
            </div>
        `;
        document.body.appendChild(popup);
        document.getElementById('virus_api_input').focus();

        document.getElementById('virus_api_save').onclick = function() {
            let key = document.getElementById('virus_api_input').value.trim();
            if (key.length === 16) {
                const oldKey = getApiKey();
                localStorage.setItem('virus_api_key', key);
                document.body.removeChild(popup);
                // Clear cached data if key changed
                if (oldKey !== key) {
                    localStorage.removeItem(LS_KEY);
                }
                if (callback) callback(key);
            } else {
                alert('Please enter a valid 16-character Torn limited API key.');
            }
        };
        document.getElementById('virus_api_cancel').onclick = function() {
            document.body.removeChild(popup);
        };
    }

    function findSidebar() {
        // Only look for Torn Tools sidebar
        const element = document.querySelector('.tt-sidebar-information');
        if (element) {
            return element;
        }
        return null;
    }

    function waitForSidebar(callback, maxAttempts = 20, attempt = 1) {
        const sidebar = findSidebar();
        if (sidebar) {
            callback();
            return;
        }
        
        if (attempt >= maxAttempts) {
            return;
        }
        
        // Exponential backoff: 100ms, 200ms, 400ms, 800ms, etc.
        const delay = Math.min(100 * Math.pow(2, attempt - 1), 5000);
        setTimeout(() => waitForSidebar(callback, maxAttempts, attempt + 1), delay);
    }

    function updateSidebar(timerText, virusName, isClickable = false) {
        // Only use Torn Tools sidebar
        let sidebar = findSidebar();
        if (!sidebar) {
            return;
        }

        let section = document.getElementById(timerContainerID);
        if (!section) {
            section = document.createElement('section');
            section.id = timerContainerID;
            section.style.order = 2;
            section.innerHTML = `
                <a class="title" href="https://www.torn.com/pc.php" target="_blank">Virus:</a>
                <span id="virus-timer-value" class="countdown"></span>
            `;
            
            // Only append to Torn Tools sidebar
            sidebar.appendChild(section);
        }
        const timerSpan = section.querySelector('#virus-timer-value');
        if (timerSpan) {
            // Always remove old onclick handler first
            timerSpan.onclick = null;
            timerSpan.style.cursor = "";
            timerSpan.title = "";
            
            if (!getApiKey()) {
                timerSpan.textContent = "Enter limited key";
                timerSpan.style.cursor = "pointer";
                timerSpan.title = "Click to enter API key";
                timerSpan.onclick = function(e) {
                    showApiKeyPopup(() => {
                        // Clear cached data when key changes
                        localStorage.removeItem(LS_KEY);
                        runCheck();
                    });
                };
            } else {
                if (virusName && timerText !== "No virus" && !isClickable) {
                    timerSpan.textContent = timerText;
                } else if (timerText === "No virus" || isClickable) {
                    // Make clickable to edit API key
                    timerSpan.textContent = timerText;
                    timerSpan.style.cursor = "pointer";
                    timerSpan.title = "Click to edit API key";
                    timerSpan.onclick = function(e) {
                        showApiKeyPopup(() => {
                            // Clear cached data when key changes
                            localStorage.removeItem(LS_KEY);
                            runCheck();
                        });
                    };
                } else {
                    timerSpan.textContent = timerText;
                }
            }
        }
    }

    let nextTimeout = null;
    function scheduleNextCheck(seconds) {
        if (nextTimeout) clearTimeout(nextTimeout);
        nextTimeout = setTimeout(runCheck, Math.max(1000, seconds * 1000));
    }

    function runCheck() {
        if (!getApiKey()) {
            updateSidebar("Enter limited key", null);
            return;
        }
        const now = Math.floor(Date.now() / 1000);
        const info = getStoredVirusInfo();
        if (info.nextCheck && now < info.nextCheck) {
            scheduleNextCheck(info.nextCheck - now);
            // Update display with stored info while waiting
            if (info.until) {
                const timeLeft = Math.max(0, info.until - now);
                updateSidebar(formatTimer(timeLeft), info.name);
            } else {
                updateSidebar("No virus", null);
            }
            return;
        }
        fetchVirusStatus();
    }

    let liveCountdownInterval = null;

    function startLiveCountdown(secsLeft, virusName) {
        clearInterval(liveCountdownInterval);
        function tick() {
            if (secsLeft <= 0) {
                clearInterval(liveCountdownInterval);
                updateSidebar("No virus", null);
                runCheck();
                return;
            }
            updateSidebar(formatTimer(secsLeft, true), virusName);
            secsLeft--;
        }
        tick();
        liveCountdownInterval = setInterval(tick, 1000);
    }

    // Show stored timer immediately if available
    (function showStoredTimer() {
        const info = getStoredVirusInfo();
        if (info.until) {
            const now = Math.floor(Date.now() / 1000);
            const timeLeft = Math.max(0, info.until - now);
            updateSidebar(formatTimer(timeLeft), info.name);
        } else {
            updateSidebar("No virus", null);
        }
    })();

    // Add MutationObserver to detect when sidebar is added to DOM
    function setupMutationObserver() {
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    // Check if any added nodes contain sidebar elements
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            // Check if the node itself is a sidebar
                            if (node.classList && (node.classList.contains('tt-sidebar-information') || node.classList.contains('sidebar-information'))) {
                                setTimeout(() => {
                                    initializeTimer(); // This will now move the timer if needed
                                }, 100);
                            }
                            // Check if the node contains a sidebar
                            const foundSidebar = node.querySelector && (node.querySelector('.tt-sidebar-information') || node.querySelector('.sidebar-information'));
                            if (foundSidebar) {
                                setTimeout(() => {
                                    initializeTimer(); // This will now move the timer if needed
                                }, 100);
                            }
                        }
                    });
                    
                    // Also check if sidebar exists now and timer doesn't exist or is in wrong place
                    const sidebar = findSidebar();
                    const existingTimer = document.getElementById(timerContainerID);
                    
                    if (sidebar && (!existingTimer || existingTimer.parentElement !== sidebar)) {
                        // Give it a moment to settle
                        setTimeout(() => {
                            initializeTimer(); // This will now move the timer if needed
                        }, 100);
                    }
                }
            });
        });

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

        // Stop observing after 30 seconds to prevent memory leaks
        setTimeout(() => {
            observer.disconnect();
        }, 30000);
    }

    function initializeTimer() {
        const info = getStoredVirusInfo();
        
        // Check if timer already exists in wrong location
        const existingTimer = document.getElementById(timerContainerID);
        const currentSidebar = findSidebar();
        
        if (existingTimer && currentSidebar) {
            // If timer exists but is in wrong sidebar, move it
            const timerParent = existingTimer.parentElement;
            if (timerParent !== currentSidebar) {
                existingTimer.remove();
                // Force recreation by removing the element
            }
        }
        
        if (info.until) {
            const now = Math.floor(Date.now() / 1000);
            const timeLeft = Math.max(0, info.until - now);
            updateSidebar(formatTimer(timeLeft), info.name);
        } else {
            updateSidebar("No virus", null);
        }
        runCheck();
    }

    // Try to initialize immediately
    waitForSidebar(initializeTimer);
    
    // Also setup mutation observer for dynamic content
    setupMutationObserver();
    
    // Fallback: Try again after a longer delay in case extensions are slow to load
    setTimeout(() => {
        if (!document.getElementById(timerContainerID)) {
            waitForSidebar(initializeTimer);
        }
    }, 5000);
})();