Virus Status

Adds a custom icon to Torn's sidebar status icons

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Virus Status
// @namespace    https://www.torn.com/
// @version      1.0.0
// @description  Adds a custom icon to Torn's sidebar status icons
// @author       Cypher[2641265]
// @match        https://www.torn.com/*
// @run-at       document-end
// @connect      api.torn.com
// @grant        GM_xmlhttpRequest
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const CUSTOM_ICON_ID = "custom-virus-status-icon";
  const CUSTOM_ICON_CLASS = "custom-virus-status-slot";
  const PROGRAMMING_CENTER_URL = "https://www.torn.com/pc.php";
  const API_KEY_STORAGE_KEY = "torn_minimal_key";
  const API_REFRESH_INTERVAL_MS = 60000;
  const TOOLTIP_REFRESH_INTERVAL_MS = 1000;
  const VIRUS_ICON_ACTIVE_URL = "https://i.ibb.co/pBQKT8jF/Virus-Green.png";
  const VIRUS_ICON_IDLE_URL = "https://i.ibb.co/KxmNXG2v/Virus-Red.png";
  const ICON_STATE_STORAGE_KEY = "virus-status-icon-state";

  let apiRefreshTimer = null;
  let tooltipRefreshTimer = null;
  let latestVirusPayload = null;
  let latestTooltipLabel = "API Key not set";
  let tooltipElement = null;

  function getApiKey() {
    try {
      return (window.localStorage.getItem(API_KEY_STORAGE_KEY) || "").trim();
    } catch (error) {
      return "";
    }
  }

  function isApiKeySet() {
    return Boolean(getApiKey());
  }

  function ensureApiKeyStorageKeyExists() {
    try {
      if (window.localStorage.getItem(API_KEY_STORAGE_KEY) === null) {
        window.localStorage.setItem(API_KEY_STORAGE_KEY, "");
      }
    } catch (error) {
      // Ignore storage failures; users can still paste key during this session.
    }
  }

  function setApiKey(apiKey) {
    const normalized = String(apiKey || "").trim();

    try {
      window.localStorage.setItem(API_KEY_STORAGE_KEY, normalized);
    } catch (error) {
      // Ignore storage failures; runtime key will still be used for this page.
    }
  }

  function showApiKeyPopup(onSubmit) {
    const existingPopup = document.getElementById("virus-api-popup");
    if (existingPopup) existingPopup.remove();

    const popup = document.createElement("div");
    popup.id = "virus-api-popup";
    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 = "24px 18px 18px 18px";
    popup.style.borderRadius = "8px";
    popup.style.boxShadow = "0 2px 16px #000a";
    popup.style.zIndex = "999999";
    popup.style.minWidth = "320px";
    popup.style.textAlign = "center";

    popup.innerHTML = `
      <div style="font-size:1.1em;margin-bottom:10px;">Enter Minimal API Key</div>
      <input id="virus-api-key-input" type="text" placeholder="API Key" style="width:90%;padding:6px;margin-bottom:10px;border-radius:4px;border:1px solid #444;background:#111;color:#fff;">
      <br>
      <button id="virus-api-key-save" style="padding:6px 18px;border-radius:4px;border:none;background:#4caf50;color:#fff;font-weight:bold;cursor:pointer;">Save</button>
    `;

    document.body.appendChild(popup);

    const input = document.getElementById("virus-api-key-input");
    const saveButton = document.getElementById("virus-api-key-save");

    saveButton.onclick = function () {
      const value = input.value.trim();
      if (!value) return;
      onSubmit(value);
      popup.remove();
    };

    input.onkeydown = function (event) {
      if (event.key === "Enter") {
        saveButton.click();
      }
    };

    input.focus();
  }

  function addStyles() {
    if (document.getElementById("custom-virus-status-styles")) return;

    const style = document.createElement("style");
    style.id = "custom-virus-status-styles";
    style.textContent = `
			li#${CUSTOM_ICON_ID} {
				list-style: none !important;
				background: none !important;
				background-image: none !important;
			}

      #custom-virus-status-tooltip {
        position: fixed;
        z-index: 999999;
        display: none;
        padding: 7px 9px;
        border: 1px solid #2d2d2d;
        border-radius: 4px;
        background: #4a4a4a;
        background-image: linear-gradient(to bottom, #525252, #3f3f3f);
        color: #f2f2f2;
        font-size: 12px;
        line-height: 1.25;
        white-space: pre-line;
        pointer-events: none;
      }

      #custom-virus-status-tooltip.is-visible {
        display: block;
      }

			li#${CUSTOM_ICON_ID}::marker,
			li#${CUSTOM_ICON_ID}::before,
			li#${CUSTOM_ICON_ID}::after,
			li#${CUSTOM_ICON_ID} > a::before,
			li#${CUSTOM_ICON_ID} > a::after {
				content: none !important;
				display: none !important;
			}

			li#${CUSTOM_ICON_ID} > a {
				position: relative !important;
        display: flex !important;
        align-items: center;
        justify-content: center;
				background: none !important;
				background-image: none !important;
				text-decoration: none;
				overflow: visible;
			}

      li#${CUSTOM_ICON_ID} > a img {
				display: block;
        width: 16px;
        height: 16px;
        object-fit: contain;
				pointer-events: none;
        transform: translateY(-1px);
			}

			li#${CUSTOM_ICON_ID} > a:hover {
				filter: brightness(1.15);
			}
		`;

    document.head.appendChild(style);
  }

  function findStatusIconList() {
    return document.querySelector('ul[class*="status-icons___"]');
  }

  function getIconLink() {
    return document.querySelector(`li#${CUSTOM_ICON_ID} > a`);
  }

  function ensureTooltipElement() {
    if (tooltipElement && document.body.contains(tooltipElement)) {
      return tooltipElement;
    }

    tooltipElement = document.createElement("div");
    tooltipElement.id = "custom-virus-status-tooltip";
    tooltipElement.setAttribute("role", "tooltip");
    document.body.appendChild(tooltipElement);
    return tooltipElement;
  }

  function positionTooltip() {
    const link = getIconLink();
    const tooltip = ensureTooltipElement();
    if (!link || !tooltip.classList.contains("is-visible")) return;

    const linkRect = link.getBoundingClientRect();
    const tooltipRect = tooltip.getBoundingClientRect();
    const top = Math.max(8, linkRect.top - tooltipRect.height - 8);
    const left = Math.min(
      window.innerWidth - tooltipRect.width - 8,
      Math.max(8, linkRect.left + (linkRect.width - tooltipRect.width) / 2),
    );

    tooltip.style.top = `${top}px`;
    tooltip.style.left = `${left}px`;
  }

  function showTooltip() {
    const tooltip = ensureTooltipElement();
    tooltip.textContent = latestTooltipLabel;
    tooltip.classList.add("is-visible");
    positionTooltip();
  }

  function hideTooltip() {
    const tooltip = ensureTooltipElement();
    tooltip.classList.remove("is-visible");
  }

  function getNativeTooltipContent(link = getIconLink()) {
    if (!link) return null;

    const tooltipId = link.getAttribute("aria-describedby");
    if (!tooltipId) return null;

    return document.querySelector(`#${tooltipId} .ui-tooltip-content`);
  }

  function parseTimestamp(value) {
    if (value === null || value === undefined || value === "") return null;

    let timestamp = null;

    if (typeof value === "number" && Number.isFinite(value)) {
      timestamp = value < 1e12 ? value * 1000 : value;
    } else if (typeof value === "string") {
      const trimmed = value.trim();
      if (!trimmed) return null;

      if (/^\d+$/.test(trimmed)) {
        const numericValue = Number(trimmed);
        timestamp = numericValue < 1e12 ? numericValue * 1000 : numericValue;
      } else {
        const parsedValue = Date.parse(trimmed);
        if (!Number.isNaN(parsedValue)) {
          timestamp = parsedValue;
        }
      }
    }

    return timestamp;
  }

  function formatDurationRemaining(until) {
    const timestamp = parseTimestamp(until);
    if (timestamp === null) return "Unknown";

    const remainingMs = Math.max(0, timestamp - Date.now());
    const totalSeconds = Math.floor(remainingMs / 1000);
    const days = Math.floor(totalSeconds / 86400);
    const hours = Math.floor((totalSeconds % 86400) / 3600);
    const minutes = Math.floor((totalSeconds % 3600) / 60);
    const seconds = totalSeconds % 60;

    const parts = [
      `${days} ${days === 1 ? "day" : "days"}`,
      `${hours} ${hours === 1 ? "hour" : "hours"}`,
      `${minutes} ${minutes === 1 ? "minute" : "minutes"}`,
      `${seconds} ${seconds === 1 ? "second" : "seconds"}`,
    ];

    return `${parts[0]}, ${parts[1]}, ${parts[2]} and ${parts[3]}`;
  }

  function getVirusPayload(response) {
    if (!response || typeof response !== "object") return null;

    if (response.name !== undefined || response.until !== undefined) {
      return response;
    }

    if (response.virus && typeof response.virus === "object") {
      return {
        name: response.virus.item?.name,
        until: response.virus.until,
      };
    }

    if (response.data && typeof response.data === "object") {
      if (
        response.data.name !== undefined ||
        response.data.until !== undefined
      ) {
        return response.data;
      }

      if (response.data.virus && typeof response.data.virus === "object") {
        return {
          name: response.data.virus.item?.name,
          until: response.data.virus.until,
        };
      }
    }

    return null;
  }

  function buildTooltipLabel(payload) {
    if (!payload) {
      return "Virus Timer: No active virus programming";
    }

    const name = payload.name ? String(payload.name) : "Unknown";
    const remaining = formatDurationRemaining(payload.until);

    return `Planning: ${name}\n${remaining}`;
  }

  function updateIconTooltip(label) {
    latestTooltipLabel = label;

    const link = getIconLink();
    if (!link) return;

    link.setAttribute("aria-label", label);

    const tooltip = ensureTooltipElement();
    if (tooltip.classList.contains("is-visible")) {
      tooltip.textContent = label;
      positionTooltip();
    }
  }

  function syncIconAriaLabel() {
    const link = getIconLink();
    if (!link) return;
    link.setAttribute("aria-label", latestTooltipLabel);
  }

  function getStoredIconState() {
    try {
      const storedState = window.localStorage.getItem(ICON_STATE_STORAGE_KEY);
      return storedState === "idle" ? "idle" : "active";
    } catch (error) {
      return "active";
    }
  }

  function setStoredIconState(payload) {
    const state = payload ? "active" : "idle";

    try {
      window.localStorage.setItem(ICON_STATE_STORAGE_KEY, state);
    } catch (error) {
      // Ignore storage failures; icon still updates for current session.
    }

    return state;
  }

  function getIconUrlForState(state) {
    return state === "idle" ? VIRUS_ICON_IDLE_URL : VIRUS_ICON_ACTIVE_URL;
  }

  function updateIconImage(payload) {
    const link = getIconLink();
    if (!link) return;

    const iconImage = link.querySelector("img");
    if (!iconImage) return;

    const state = setStoredIconState(payload);
    iconImage.src = getIconUrlForState(state);
  }

  function requestVirusStatus() {
    const apiKey = getApiKey();
    if (!apiKey) {
      return Promise.reject(new Error("API key not set"));
    }

    const virusApiUrl = `https://api.torn.com/v2/user/virus?key=${apiKey}`;

    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: virusApiUrl,
        headers: {
          Accept: "application/json",
        },
        onload: function (response) {
          if (response.status < 200 || response.status >= 300) {
            reject(
              new Error(
                `Virus API request failed with status ${response.status}`,
              ),
            );
            return;
          }

          try {
            resolve(JSON.parse(response.responseText));
          } catch (error) {
            reject(new Error("Virus API returned invalid JSON"));
          }
        },
        onerror: function () {
          reject(new Error("Virus API request failed"));
        },
      });
    });
  }

  async function refreshVirusStatus() {
    if (!isApiKeySet()) {
      latestVirusPayload = null;
      updateIconImage(null);
      updateIconTooltip("API Key not set");
      return;
    }

    try {
      const response = await requestVirusStatus();
      latestVirusPayload = getVirusPayload(response);
      updateIconImage(latestVirusPayload);
      updateIconTooltip(buildTooltipLabel(latestVirusPayload));
    } catch (error) {
      latestVirusPayload = null;
      updateIconTooltip("Virus Timer: Unable to load virus status");
      console.error("Virus Status userscript", error);
    }
  }

  function refreshTooltipCountdown() {
    if (!latestVirusPayload) return;
    updateIconTooltip(buildTooltipLabel(latestVirusPayload));
  }

  function createCustomIcon(statusList) {
    const templateItem = statusList.querySelector("li");
    const li = templateItem
      ? templateItem.cloneNode(true)
      : document.createElement("li");

    li.id = CUSTOM_ICON_ID;
    li.className = [templateItem?.className || "", CUSTOM_ICON_CLASS]
      .filter(Boolean)
      .join(" ");

    let link = li.querySelector("a");
    if (!link) {
      link = document.createElement("a");
      li.appendChild(link);
    }

    link.href = PROGRAMMING_CENTER_URL;
    link.setAttribute("aria-label", latestTooltipLabel);
    link.setAttribute("tabindex", "0");
    link.setAttribute("data-is-tooltip-opened", "false");
    link.removeAttribute("title");
    link.replaceChildren();

    const iconImage = document.createElement("img");
    iconImage.src = getIconUrlForState(getStoredIconState());
    iconImage.alt = "";
    iconImage.setAttribute("aria-hidden", "true");
    iconImage.decoding = "async";
    link.appendChild(iconImage);

    link.addEventListener("click", function (event) {
      event.preventDefault();

      if (!isApiKeySet()) {
        showApiKeyPopup(function (enteredKey) {
          setApiKey(enteredKey);
          latestTooltipLabel = "Virus Timer: Loading...";
          updateIconTooltip(latestTooltipLabel);
          refreshVirusStatus();
        });
        return;
      }

      window.location.href = PROGRAMMING_CENTER_URL;
    });

    link.addEventListener("mouseenter", showTooltip);
    link.addEventListener("mouseleave", hideTooltip);
    link.addEventListener("focus", showTooltip);
    link.addEventListener("blur", hideTooltip);
    link.addEventListener("mouseleave", syncIconAriaLabel);
    link.addEventListener("blur", syncIconAriaLabel);

    return li;
  }

  function injectCustomIcon() {
    const statusList = findStatusIconList();
    if (!statusList) return;

    const existing = document.getElementById(CUSTOM_ICON_ID);
    if (existing) {
      if (existing.parentElement !== statusList) {
        statusList.appendChild(existing);
      }
      return;
    }

    statusList.appendChild(createCustomIcon(statusList));
  }

  function init() {
    ensureApiKeyStorageKeyExists();
    addStyles();
    injectCustomIcon();
    refreshVirusStatus();

    if (apiRefreshTimer === null) {
      apiRefreshTimer = window.setInterval(
        refreshVirusStatus,
        API_REFRESH_INTERVAL_MS,
      );
    }

    if (tooltipRefreshTimer === null) {
      tooltipRefreshTimer = window.setInterval(
        refreshTooltipCountdown,
        TOOLTIP_REFRESH_INTERVAL_MS,
      );
    }

    window.addEventListener("scroll", positionTooltip, true);
    window.addEventListener("resize", positionTooltip);

    const observer = new MutationObserver(() => {
      injectCustomIcon();
    });

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

  if (document.body) {
    init();
  } else {
    window.addEventListener("DOMContentLoaded", init, { once: true });
  }
})();