Torn: Refill Blood Bag Reminder

Show a blood-bag icon when Life is full (with Torn-style tooltip)

// ==UserScript==
// @name         Torn: Refill Blood Bag Reminder
// @namespace    torn.tools.reminders
// @version      2.1.0
// @description  Show a blood-bag icon when Life is full (with Torn-style tooltip)
// @author       You
// @license      MIT
// @match        https://www.torn.com/*
// @match        https://torn.com/*
// @icon         https://www.torn.com/favicon.ico
// @homepageURL  https://greasyfork.org/en/scripts/548072-torn-xanax-pre-use-blood-bag-reminder-full-life-icon-yellow-tip-native-style
// @supportURL   https://greasyfork.org/en/scripts/548072-torn-xanax-pre-use-blood-bag-reminder-full-life-icon-yellow-tip-native-style/feedback
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==
 
(function () {
  'use strict';
 
  const CONFIG = {
    // Life bar + sidebar
    lifeBarSelector: 'a[class*="life"][class*="bar"]',
    lifeValueSelector: '[class^="bar-value"]',
    lifeDescrSelector:  '[class^="bar-descr"]',
    progressWrapSelector: 'div[class*="progress"]',
    statusIconsSelector: 'ul[class*="status-icons"]',
 
    // Our icon
    fullLifeIconId: 'tm-full-life-bloodbag',
    bloodBagPng: 'https://i.postimg.cc/mkZ1T68H/blood-bag-2.png',
    bloodBagTarget: 'https://www.torn.com/factions.php?step=your&type=1#/tab=armoury&start=0&sub=medical',
 
    pollMs: 2000,
    arrowExtraNudgePx: 14,   // centers arrow on our custom icon
    tooltipTopNudgePx: -4,   // small vertical nudge to better match native
  };
 
  // 👇 Declare before anything can call scheduleLifeCheck (fixes TDZ crash)
  let lifeCheckScheduled = false;
 
  // One-time CSS: reset sprite bleed + allow UL to grow
  ensureResetStyles();
  ensureUlWrapFix();
 
  // Observe DOM changes (SPA) and poll
  const mo = new MutationObserver(() => {
    scheduleLifeCheck();
  });
  mo.observe(document.documentElement, { childList: true, subtree: true });
  setInterval(scheduleLifeCheck, CONFIG.pollMs);
 
  // Initial draw
  scheduleLifeCheck();
 
  // ===== Core =====
  function scheduleLifeCheck() {
    if (lifeCheckScheduled) return;
    lifeCheckScheduled = true;
    requestAnimationFrame(() => {
      lifeCheckScheduled = false;
      updateFullLifeIcon();
    });
  }
 
  function getLife() {
    const lifeBar = document.querySelector(CONFIG.lifeBarSelector);
    if (!lifeBar) return null;
 
    const valNode =
      lifeBar.querySelector(CONFIG.lifeValueSelector) ||
      Array.from(lifeBar.querySelectorAll('p, span, div'))
        .find((n) => /\d[\d,]*\s*\/\s*\d[\d,]*/.test(n.textContent || ''));
 
    let current = null, max = null;
    const text = (valNode?.textContent || '').trim();
    const m = text.match(/(\d[\d,]*)\s*\/\s*(\d[\d,]*)/);
    if (m) {
      current = parseInt(m[1].replace(/,/g, ''), 10);
      max     = parseInt(m[2].replace(/,/g, ''), 10);
    }
 
    const descrText    = (lifeBar.querySelector(CONFIG.lifeDescrSelector)?.textContent || '').trim().toUpperCase();
    const progressWrap = lifeBar.querySelector(CONFIG.progressWrapSelector);
    const hasFullClass = progressWrap && /\bfull___/.test(progressWrap.className);
 
    if (current != null && max != null && max > 0) {
      const pct = Math.round((current / max) * 100);
      const forcedPct = (descrText === 'FULL' || hasFullClass) ? 100 : pct;
      return { current, max, pct: forcedPct };
    }
 
    if (descrText === 'FULL' || hasFullClass) {
      const maxGuess = m ? parseInt(m[2].replace(/,/g, ''), 10) : null;
      return { current: maxGuess ?? 0, max: maxGuess ?? 0, pct: 100 };
    }
    return null;
  }
 
  function updateFullLifeIcon() {
    const statusUl = document.querySelector(CONFIG.statusIconsSelector);
    if (!statusUl) return;
 
    const existing = document.getElementById(CONFIG.fullLifeIconId);
    const life = getLife();
    const shouldShow = !!life && life.pct === 100;
 
    if (shouldShow) {
      const label = `Full Life: ${formatNum(life.current)} / ${formatNum(life.max)} — consider donating blood`;
      if (existing) {
        updateIconTooltip(existing, label);
        return;
      }
      const li = buildBloodBagIcon(label);
      statusUl.appendChild(li);
    } else if (existing) {
      existing.remove();
    }
  }
 
  function buildBloodBagIcon(tooltipText) {
    const li = document.createElement('li');
    li.id = CONFIG.fullLifeIconId;
    li.style.background = 'none';
    li.style.animation = 'tmPulse 900ms ease-out 1';
 
    const a = document.createElement('a');
    a.href = CONFIG.bloodBagTarget;         // same-tab navigation (no target)
    a.setAttribute('aria-label', tooltipText);
    a.tabIndex = 0;
    a.setAttribute('data-is-tooltip-opened', 'false');
 
    const img = document.createElement('img');
    img.src = CONFIG.bloodBagPng;
    img.alt = 'Blood Bag';
    img.width = 17;
    img.height = 17;
    img.style.display = 'block';
 
    a.appendChild(img);
    li.appendChild(a);
 
    // Native-style tooltip
    enableNativeLikeTooltip(a);
 
    if (!document.getElementById('tm-pulse-style')) {
      const style = document.createElement('style');
      style.id = 'tm-pulse-style';
      style.textContent = `
        @keyframes tmPulse {
          0%   { transform: scale(0.9); }
          60%  { transform: scale(1.1); }
          100% { transform: scale(1.0); }
        }
      `;
      document.head.appendChild(style);
    }
    return li;
  }
 
  function updateIconTooltip(li, text) {
    const a = li.querySelector('a');
    if (!a) return;
    a.setAttribute('aria-label', text);
    if (typeof a.__tmUpdateTipText === 'function') a.__tmUpdateTipText(text);
  }
 
  // ===== Tooltip (native-style mimic) =====
  function enableNativeLikeTooltip(anchor) {
    let tipEl = null;
    let hideTimer = null;
 
    const CLS = {
      tip: 'tooltip___aWICR tooltipCustomClass___gbI4V',
      arrowWrap: 'arrow___yUDKb top___klE_Y',
      arrowIcon: 'arrowIcon___KHyjw',
    };
 
    function buildTooltip(text) {
      const el = document.createElement('div');
      el.className = CLS.tip;
      el.setAttribute('role', 'tooltip');
      el.setAttribute('tabindex', '-1');
      el.style.position = 'absolute';
      el.style.transitionProperty = 'opacity';
      el.style.transitionDuration = '200ms';
      el.style.opacity = '0';
 
      // Title (bold) + second line (like native)
      const [title, subtitle] = parseTwoLines(text);
      const b = document.createElement('b');
      b.textContent = title;
      el.appendChild(b);
 
      if (subtitle) {
        const div = document.createElement('div');
        div.textContent = subtitle;
        el.appendChild(div);
      }
 
      const arrowWrap = document.createElement('div');
      arrowWrap.className = CLS.arrowWrap;
      const arrowIcon = document.createElement('div');
      arrowIcon.className = CLS.arrowIcon;
      arrowWrap.appendChild(arrowIcon);
      el.appendChild(arrowWrap);
 
      return el;
    }
 
    function setText(text) {
      if (!tipEl) return;
      const [title, subtitle] = parseTwoLines(text);
      const b = tipEl.querySelector('b');
      if (b) b.textContent = title;
      let sub = b?.nextElementSibling;
      if (subtitle) {
        if (!sub || sub.tagName !== 'DIV') {
          sub = document.createElement('div');
          b.after(sub);
        }
        sub.textContent = subtitle;
      } else if (sub) {
        sub.remove();
      }
    }
 
    function parseTwoLines(text) {
      // Expect aria-label like: "Full Life: 1,230 / 1,230 — consider donating blood"
      // We render as two lines:
      // 1) "Full Life"
      // 2) "Considering filling blood bags" (or whatever you set elsewhere)
      // If your aria-label is already "Line1\nLine2", we’ll use that split.
      if (text.includes('\n')) {
        const [a, b] = text.split('\n');
        return [a.trim(), (b || '').trim()];
      }
      // Default fallback:
      return ['Full Life', 'Considering filling blood bags'];
    }
 
    function positionTooltip() {
      if (!tipEl) return;
 
      const r = anchor.getBoundingClientRect();
      const ew = tipEl.offsetWidth;
      const eh = tipEl.offsetHeight;
 
      // Top position: above the icon, with small nudge to better match native
      let left = Math.round(r.left + (r.width - ew) / 2);
      let top  = Math.round(r.top - eh - 10 + CONFIG.tooltipTopNudgePx);
 
      left = Math.max(8, Math.min(left, window.innerWidth - ew - 8));
      if (top < 8) {
        top = Math.round(r.bottom + 10);
      }
 
      tipEl.style.left = `${left}px`;
      tipEl.style.top  = `${top}px`;
 
      // Arrow centering calculation based on icon center + optional nudge
      const arrow = tipEl.querySelector(`.${CLS.arrowWrap.split(' ')[0]}`);
      if (arrow) {
        const padL = 0;
        const arrowW = 12;
        const iconCenter = r.left + r.width / 2;
        let arrowLeft = Math.round(iconCenter - left - padL - arrowW / 2);
        arrowLeft += CONFIG.arrowExtraNudgePx; // user-tuned nudge for our icon
        arrow.style.left = `${arrowLeft}px`;
      }
    }
 
    function showTip() {
      clearTimeout(hideTimer);
      const text = anchor.getAttribute('aria-label');
      if (!text) return;
 
      if (!tipEl) {
        tipEl = buildTooltip(text);
        document.body.appendChild(tipEl);
        anchor.__tmTipEl = tipEl;
      } else {
        setText(text);
      }
 
      anchor.setAttribute('data-is-tooltip-opened', 'true');
 
      // First layout offscreen, then position + fade in
      tipEl.style.opacity = '0';
      tipEl.style.left = '-9999px';
      tipEl.style.top = '-9999px';
      requestAnimationFrame(() => {
        positionTooltip();
        requestAnimationFrame(() => {
          if (tipEl) tipEl.style.opacity = '1';
        });
      });
    }
 
    function hideTip(immediate = false) {
      if (!tipEl) return;
      anchor.setAttribute('data-is-tooltip-opened', 'false');
 
      if (immediate) {
        tipEl.remove();
        anchor.__tmTipEl = null;
        tipEl = null;
        return;
      }
      tipEl.style.opacity = '0';
      hideTimer = setTimeout(() => {
        tipEl?.remove();
        anchor.__tmTipEl = null;
        tipEl = null;
      }, 210);
    }
 
    // Expose updater so we can refresh text on life changes
    anchor.__tmUpdateTipText = (text) => setText(text);
 
    anchor.addEventListener('mouseenter', showTip);
    anchor.addEventListener('mouseleave', () => hideTip(false));
    anchor.addEventListener('focus', showTip);
    anchor.addEventListener('blur', () => hideTip(true));
    window.addEventListener('scroll', () => hideTip(true), { passive: true });
  }
 
  // ===== CSS guards =====
  function ensureResetStyles() {
    if (document.getElementById('tm-full-life-icon-reset')) return;
    const s = document.createElement('style');
    s.id = 'tm-full-life-icon-reset';
    s.textContent = `
      #tm-full-life-bloodbag,
      #tm-full-life-bloodbag a,
      #tm-full-life-bloodbag img {
        background: none !important;
        background-image: none !important;
        -webkit-mask: none !important;
        mask: none !important;
        box-shadow: none !important;
        border: none !important;
      }
      #tm-full-life-bloodbag::before,
      #tm-full-life-bloodbag::after,
      #tm-full-life-bloodbag a::before,
      #tm-full-life-bloodbag a::after { content: none !important; }
    `;
    document.head.appendChild(s);
  }
 
  function ensureUlWrapFix() {
    if (document.getElementById('tm-status-ul-fix')) return;
    const s = document.createElement('style');
    s.id = 'tm-status-ul-fix';
    s.textContent = `
      ul[class*="status-icons"] {
        height: auto !important;
        overflow: visible !important;
      }
    `;
    document.head.appendChild(s);
  }
 
  // ===== utils =====
  function formatNum(n) { try { return n.toLocaleString(); } catch { return String(n); } }
})();