// ==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); } }
})();