Track whether revived players pay voluntarily, and show other players' payment history before you revive them.
// ==UserScript==
// @name Torn Revive Payment Tracker
// @namespace torn-revive-payment-tracker
// @version 0.5.5
// @description Track whether revived players pay voluntarily, and show other players' payment history before you revive them.
// @author LordVillage
// @license No redistribution without permission from the author
// @match https://www.torn.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @connect qiverof64h.execute-api.eu-west-1.amazonaws.com
// @connect update.greasyfork.org
// @run-at document-start
// ==/UserScript==
/*
* TornPDA compatibility, confirmed against Manuito83/torn-pda's own
* userscripts/README.md, GMforPDA.user.js and TornPDA_API.js:
* - GM_setValue/GM_getValue and GM_xmlhttpRequest ARE shimmed (backed by
* localStorage and PDA_httpGet/PDA_httpPost respectively) with the same
* call shape Tampermonkey uses, so those needed no changes.
* - GM_registerMenuCommand/GM_unregisterMenuCommand do NOT exist in that
* shim. Calling them unconditionally (as this script used to) throws a
* ReferenceError inside TornPDA and aborts the rest of the script - the
* menu-command calls below are guarded behind a feature check for this
* reason. There's no on-page fallback for setting the key manually when
* the menu is unavailable (see PDA_APIKEY_PLACEHOLDER below for why
* that's not needed there); Contract mode still gets a floating on-page
* toggle though, shown only on pages where a revive button (a.revive)
* is actually present.
* - PDA_APIKEY_PLACEHOLDER below is a documented magic string
* ("###PDA-APIKEY###") that TornPDA text-substitutes for the user's
* real Torn API key before running the script; left untouched by any
* normal browser/Tampermonkey. A key set via GM_setValue (only reachable
* through the menu, so desktop-only) always takes priority over it.
*/
(function () {
"use strict";
// Unconditional, low-volume startup log - the whole point is to answer
// "is the script running at all on this page" without guesswork if
// something appears silently broken (readyState included since @run-at
// document-start means this may fire before the page has finished loading).
console.info("[Revive Tracker] script starting", {
href: window.location.href,
readyState: document.readyState,
});
const API_BASE_URL = "https://qiverof64h.execute-api.eu-west-1.amazonaws.com/Prod";
// Kept in sync with the @version header above, not read from GM_info -
// confirmed live that TornPDA's GM_info shim doesn't populate
// .script.version, which was reporting the literal string "undefined" to
// the backend (encodeURIComponent(undefined) === "undefined").
const SCRIPT_VERSION = "0.5.5";
const GREASYFORK_SCRIPT_ID = "586027";
const GREASYFORK_META_URL = `https://update.greasyfork.org/scripts/${GREASYFORK_SCRIPT_ID}/Torn%20Revive%20Payment%20Tracker.meta.js`;
const GREASYFORK_SCRIPT_URL = `https://greasyfork.org/scripts/${GREASYFORK_SCRIPT_ID}`;
const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
const TILL_PROFILE_ID = "3254720";
const REVIVE_TEXT_PATTERN = /You successfully revived/i;
const PROFILE_ID_PATTERN = /XID=(\d+)/;
const RECORDED_NODES = new WeakSet();
const HAS_MENU_COMMANDS =
typeof GM_registerMenuCommand === "function" && typeof GM_unregisterMenuCommand === "function";
const PDA_APIKEY_PLACEHOLDER = "###PDA-APIKEY###";
function getPdaApiKey() {
// Untouched (still contains the literal placeholder) outside TornPDA.
return PDA_APIKEY_PLACEHOLDER.indexOf("###") === -1 ? PDA_APIKEY_PLACEHOLDER : null;
}
function getApiKey() {
return GM_getValue("torn_api_key", null) || getPdaApiKey();
}
function promptForApiKey() {
const key = window.prompt(
"Torn Revive Tracker: paste a Torn API key with at least 'Limited' " +
"access (create one at torn.com/preferences.php#tab=api). It's " +
"stored locally in your browser, and also sent to this script's " +
"backend (over HTTPS, encrypted at rest) so it can check your " +
"Torn activity on your behalf for revive/payment tracking."
);
if (key) {
GM_setValue("torn_api_key", key.trim());
}
return key ? key.trim() : null;
}
function ensureApiKey() {
return getApiKey() || promptForApiKey();
}
if (HAS_MENU_COMMANDS) {
GM_registerMenuCommand("Set Torn API key", promptForApiKey);
}
// No on-page fallback when menu commands are unavailable (e.g. TornPDA) -
// that platform auto-detects its own injected key (see getPdaApiKey), so
// there's no floating settings button cluttering the screen there anymore.
// A manual override is only reachable via GM_setValue on platforms that
// do have a menu.
// Contract mode: while ON, revives are recorded as exempt up front (e.g.
// a faction contract with no payment expected) - the backend skips
// verification/payment tracking for them entirely.
const CONTRACT_MODE_KEY = "contract_mode";
let contractMenuCommandId = null;
function isContractMode() {
return !!GM_getValue(CONTRACT_MODE_KEY, false);
}
function refreshContractMenuCommand() {
if (!HAS_MENU_COMMANDS) return;
if (contractMenuCommandId !== null) {
GM_unregisterMenuCommand(contractMenuCommandId);
}
const label = `Contract mode: ${isContractMode() ? "ON" : "OFF"}`;
contractMenuCommandId = GM_registerMenuCommand(label, toggleContractMode);
}
// Toast confirming the new state - toggling is otherwise silent (a menu
// command or a small fixed-position button), easy to fire by accident and
// not notice. Positioned just above the contract-mode button so it reads
// as tied to that control even on pages where the button isn't shown
// (e.g. toggled via the Tampermonkey menu instead).
function showContractModeToast(on) {
if (!document.body) return;
const toast = document.createElement("div");
toast.textContent = on
? "Contract mode enabled - not expecting payment for these revives"
: "Contract mode disabled - payment tracking resumed";
toast.style.cssText =
"position:fixed;bottom:52px;left:8px;z-index:2147483647;" +
"background:#023b4e;color:#fff;padding:8px 12px;border-radius:6px;" +
"font-size:13px;font-family:Arial, Helvetica, sans-serif;max-width:240px;" +
"box-shadow:0 1px 4px rgba(0,0,0,0.4);opacity:0;transition:opacity 0.4s ease;";
document.body.appendChild(toast);
// Set opacity in a follow-up frame so the browser paints the opacity:0
// state first - otherwise the transition has nothing to animate from.
requestAnimationFrame(() => {
toast.style.opacity = "1";
});
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 400);
}, 3000);
}
function toggleContractMode() {
const newState = !isContractMode();
GM_setValue(CONTRACT_MODE_KEY, newState);
refreshContractMenuCommand();
if (contractModeButtonUpdateLabel) contractModeButtonUpdateLabel();
showContractModeToast(newState);
}
refreshContractMenuCommand();
let contractModeButtonShown = false;
let contractModeButtonUpdateLabel = null;
function injectContractModeButtonIfPresent() {
if (contractModeButtonShown) return;
if (!document.querySelector("a.revive")) return;
contractModeButtonShown = true;
// Styled like Torn's own bottom-toolbar icons - colors and the subtle
// top-to-bottom gradient sampled directly from a live screenshot of that
// bar (gray #5b5b5b->#525252 inactive, teal #026181->#023b4e active).
// Rounded only at the top and flush with the bottom edge (no bottom
// offset, square bottom corners) so it reads as a tab built into the
// edge of the page rather than a floating overlay.
const button = document.createElement("div");
button.style.cssText =
"position:fixed;bottom:0;left:8px;z-index:2147483647;" +
"width:40px;height:40px;display:flex;align-items:center;justify-content:center;" +
"border-radius:8px 8px 0 0;cursor:pointer;" +
"box-shadow:0 1px 4px rgba(0,0,0,0.4),inset 0 1px 1px rgba(255,255,255,0.15),inset 0 -6px 10px rgba(0,0,0,0.25);";
const dollarIcon = (crossedOut) =>
'<svg viewBox="0 0 24 24" width="22" height="22" style="filter:drop-shadow(0 1px 1px rgba(0,0,0,0.5));">' +
'<text x="12" y="17" text-anchor="middle" font-size="17" font-weight="bold" ' +
'font-family="Arial, Helvetica, sans-serif" fill="#fff">$</text>' +
(crossedOut ? '<line x1="4" y1="20" x2="20" y2="4" stroke="#fff" stroke-width="2" stroke-linecap="round"/>' : "") +
"</svg>";
contractModeButtonUpdateLabel = () => {
const on = isContractMode();
// ON = contract/exempt = not expecting payment, so the $ gets struck
// through; OFF = normal revive = expecting payment = plain $.
button.innerHTML = dollarIcon(on);
button.title = `Contract mode: ${on ? "ON" : "OFF"} (click to toggle - no payment tracking for revives while ON)`;
button.style.backgroundImage = on
? "linear-gradient(#026181, #023b4e)"
: "linear-gradient(#5b5b5b, #525252)";
};
contractModeButtonUpdateLabel();
button.addEventListener("click", toggleContractMode);
document.body.appendChild(button);
}
function whenBodyReady(callback) {
if (document.body) {
callback();
return;
}
// @run-at document-start means document.body may not exist yet -
// <html> does exist that early, though, so watch it for body's
// insertion rather than waiting all the way for DOMContentLoaded. Lets
// DOM-dependent setup (revive detection, hospital rows, the
// contract-mode button) attach as soon as the page structurally can
// support it, not just once the whole document has finished parsing.
const observer = new MutationObserver(() => {
if (document.body) {
observer.disconnect();
callback();
}
});
observer.observe(document.documentElement, { childList: true });
}
function waitForCondition(check, callback) {
const existing = check();
if (existing) {
callback(existing);
return;
}
// Unlike whenBodyReady, this has to watch for something specific deep in
// the page (e.g. the name heading), which can appear well after <body>
// itself does - a one-shot check gated only on document.body existing
// fires too early under document-start and silently finds nothing (the
// bug this replaced). subtree:true on <html> catches it appearing
// anywhere below, whenever that happens.
const observer = new MutationObserver(() => {
const result = check();
if (result) {
observer.disconnect();
callback(result);
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
function waitForElement(selector, callback) {
waitForCondition(() => document.querySelector(selector), callback);
}
function watchForReviveButton() {
injectContractModeButtonIfPresent();
if (contractModeButtonShown) return;
const observer = new MutationObserver(() => {
injectContractModeButtonIfPresent();
if (contractModeButtonShown) observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
}
function apiRequest(method, path, body) {
const apiKey = getApiKey();
return new Promise((resolve, reject) => {
// script_version travels as a query param rather than a header -
// confirmed live that TornPDA's native GET handler doesn't forward
// custom headers the way Tampermonkey/POST do, silently dropping it,
// while the URL itself is unaffected on every platform.
const separator = path.includes("?") ? "&" : "?";
const url = `${API_BASE_URL}${path}${separator}script_version=${encodeURIComponent(SCRIPT_VERSION)}`;
const headers = Object.assign(
{ "Content-Type": "application/json" },
apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
);
GM_xmlhttpRequest({
method,
url,
headers,
data: body ? JSON.stringify(body) : undefined,
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
try {
resolve(JSON.parse(response.responseText));
} catch (err) {
reject(err);
}
} else {
const err = new Error(`API ${method} ${path} failed: ${response.status} ${response.responseText}`);
err.status = response.status;
try {
err.body = JSON.parse(response.responseText);
} catch (parseErr) {
err.body = null;
}
reject(err);
}
},
onerror: reject,
});
});
}
function compareVersions(a, b) {
const partsA = a.split(".").map(Number);
const partsB = b.split(".").map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const diff = (partsA[i] || 0) - (partsB[i] || 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
return 0;
}
// Greasy Fork publishes a lightweight ".meta.js" alongside every script -
// just the UserScript header block, not the full code - specifically so
// consumers can check for updates without downloading the whole thing.
// Checked at most once every 6h (cached via GM_setValue) since this fires
// on every page load; mainly useful on TornPDA, which has no equivalent of
// Tampermonkey's own update-available badge on the extension icon.
function checkForScriptUpdate() {
const currentVersion = SCRIPT_VERSION;
const lastCheckedAt = Number(GM_getValue("update_check_at", 0));
if (Date.now() - lastCheckedAt < UPDATE_CHECK_INTERVAL_MS) {
const cachedLatest = GM_getValue("update_latest_version", null);
return Promise.resolve(
cachedLatest && compareVersions(cachedLatest, currentVersion) > 0 ? cachedLatest : null
);
}
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: GREASYFORK_META_URL,
onload: (response) => {
GM_setValue("update_check_at", Date.now());
const match = /@version\s+([\d.]+)/.exec(response.responseText || "");
const latestVersion = match ? match[1] : null;
GM_setValue("update_latest_version", latestVersion);
if (latestVersion && compareVersions(latestVersion, currentVersion) > 0) {
console.info(
`[Revive Tracker] Update available: v${latestVersion} (currently running v${currentVersion}) - ${GREASYFORK_SCRIPT_URL}`
);
resolve(latestVersion);
} else {
resolve(null);
}
},
onerror: (err) => {
console.warn("[Revive Tracker] Update check failed:", err);
resolve(null);
},
});
});
}
// Fired immediately (not gated on DOM/page type) so it runs in parallel
// with page parsing and its console notice shows up on every page, not
// just the home page.
const updateCheckPromise = checkForScriptUpdate();
let noticeShown = false;
function showNotice(message, action) {
if (noticeShown) return;
noticeShown = true;
const banner = document.createElement("div");
// Max 32-bit z-index so the notice sits above everything - Torn's own
// overlays and, importantly, this script's own fixed buttons/toasts (which
// also use 2147483647). At the old 99999 the "set your API key" prompt
// could end up hidden behind those, which is the worst thing to hide.
banner.style.cssText =
"position:fixed;top:0;left:0;right:0;z-index:2147483647;" +
"background:#c62828;color:#fff;padding:10px 16px;font-size:13px;" +
"font-weight:bold;text-align:center;box-shadow:0 2px 6px rgba(0,0,0,0.3);";
const text = document.createElement("span");
text.textContent = message + " ";
banner.appendChild(text);
if (action) {
if (action.href) {
const link = document.createElement("a");
link.href = action.href;
link.target = "_blank";
link.rel = "noopener";
link.style.cssText = "color:#fff;text-decoration:underline;";
link.textContent = action.label;
banner.appendChild(link);
} else if (action.onClick) {
const button = document.createElement("span");
button.textContent = action.label;
button.style.cssText = "color:#fff;text-decoration:underline;cursor:pointer;";
button.addEventListener("click", action.onClick);
banner.appendChild(button);
}
}
const dismiss = document.createElement("span");
dismiss.textContent = " ×";
dismiss.style.cssText = "margin-left:12px;cursor:pointer;font-weight:bold;";
dismiss.addEventListener("click", () => banner.remove());
banner.appendChild(dismiss);
document.body.prepend(banner);
}
function handleReputationFetchError(err) {
if (err.status === 402) {
showNotice(err.body && err.body.error ? err.body.error : "Revive Tracker: no active subscription.", {
href: `https://www.torn.com/profiles.php?XID=${TILL_PROFILE_ID}`,
label: "Send Xanax here",
});
} else if (err.status === 401) {
showNotice("Revive Tracker: set your Torn API key to see reputation badges.", {
onClick: promptForApiKey,
label: "Set it now",
});
} else if (err.status === 403) {
// Key verified but lacks the Limited access the tool needs to track
// payments - tell them what's wrong and let them set a better key.
showNotice(
err.body && err.body.error
? `Revive Tracker: ${err.body.error}`
: "Revive Tracker: your API key needs at least 'Limited' access to track payments.",
{ onClick: promptForApiKey, label: "Set a new key" }
);
} else {
console.error("[Revive Tracker] Failed to fetch reputation:", err);
}
}
function extractTargetId(node) {
const match = node.innerHTML && node.innerHTML.match(PROFILE_ID_PATTERN);
if (match) return parseInt(match[1], 10);
const currentUrlMatch = window.location.href.match(/(?:XID|user2ID)=(\d+)/);
return currentUrlMatch ? parseInt(currentUrlMatch[1], 10) : null;
}
// Best-effort only, unconfirmed live - the confirm-revive container's own
// profile link text is assumed to be the target's display name (matches
// how extractTargetId reads the same link's href), but this hasn't been
// checked against a real hospital-list DOM snapshot the way the rest of
// this container's structure was. Purely cosmetic if wrong: every
// downstream use of target_username tolerates it being absent (see
// record_revive.py), falling back to "Player #<id>".
function extractTargetUsername(node) {
if (!node.querySelector) return null;
const link = node.querySelector('a[href*="XID="]');
const text = link && link.textContent && link.textContent.trim();
return text || null;
}
// Returns the recorded revive's {revive_id, target_id, target_username}
// for showThankYouPromptToast to build a payment-request link from, or
// null if nothing should be nudged - either the call failed, or contract
// mode means no payment was ever expected for this one.
async function recordRevive(targetId, targetUsername) {
const apiKey = ensureApiKey();
if (!apiKey) return null;
const contract = isContractMode();
try {
const response = await apiRequest("POST", "/revives", {
target_id: targetId,
revived_at: Math.floor(Date.now() / 1000),
contract,
target_username: targetUsername || undefined,
});
console.info(
`[Revive Tracker] Recorded revive of player ${targetId}` +
(contract ? " (contract mode: no payment tracked)" : "")
);
if (contract) return null;
return { revive_id: response.revive_id, target_id: targetId, target_username: targetUsername };
} catch (err) {
console.error("[Revive Tracker] Failed to record revive:", err);
return null;
}
}
// Prompt shown right after a successful (non-contract) revive - unlike
// the 12h-later "Pending Payment Reminders" nudge, this fires
// immediately, so it's framed as a thank-you-in-advance rather than a
// follow-up (see THANK_YOU_MESSAGE). Persistent (no auto-fade, unlike
// showContractModeToast) since the whole point is for the reviewer to
// actually click through - it just sits until dismissed or used.
// Positioned bottom-right, mirroring the contract-mode toast's
// bottom-left slot, so the two never overlap.
function showThankYouPromptToast(revive) {
if (!document.body) return;
const toast = document.createElement("div");
toast.style.cssText =
"position:fixed;bottom:8px;right:8px;z-index:2147483647;" +
"background:#023b4e;color:#fff;padding:10px 14px;border-radius:8px;" +
"font-size:13px;font-family:Arial, Helvetica, sans-serif;max-width:260px;" +
"box-shadow:0 1px 4px rgba(0,0,0,0.4);";
const text = document.createElement("div");
text.textContent = "Revive recorded. Let them know a donation's expected?";
toast.appendChild(text);
const link = document.createElement("a");
link.href = buildPaymentRequestLink(revive);
link.textContent = "Send thank-you & payment note";
link.style.cssText = "color:#8ecdf0;display:inline-block;margin-top:6px;font-weight:bold;text-decoration:none;";
// Same same-tick-before-navigation pattern as the reminder link (see
// renderUnpaidRevivesRows) - just tagged "thank_you" so
// injectComposePagePrefill picks the warmer message/subject.
link.addEventListener("click", () => {
storePendingPaymentRequest(revive, "thank_you");
toast.remove();
});
toast.appendChild(link);
const dismiss = document.createElement("span");
dismiss.textContent = " ×";
dismiss.style.cssText = "margin-left:10px;cursor:pointer;font-weight:bold;color:#9fb3bd;";
dismiss.addEventListener("click", () => toast.remove());
toast.appendChild(dismiss);
document.body.appendChild(toast);
}
function watchForRevive() {
// The hospital list ships one empty <div class="confirm-revive"> per row
// up front. Torn fills the row's own div in place (e.g. via .html(text
// + link)), which adds the confirmation text and the profile <a> as
// sibling nodes rather than one wrapped node - so the check has to look
// at the container's combined textContent, not at each added node.
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
const container = mutation.target;
if (!(container instanceof Element)) continue;
if (!container.classList.contains("confirm-revive")) continue;
if (RECORDED_NODES.has(container)) continue;
const text = container.textContent || "";
if (REVIVE_TEXT_PATTERN.test(text)) {
RECORDED_NODES.add(container);
const targetId = extractTargetId(container);
if (targetId) {
recordRevive(targetId, extractTargetUsername(container)).then((revive) => {
if (revive) showThankYouPromptToast(revive);
});
} else {
console.warn("[Revive Tracker] Detected a revive but couldn't determine the target's player id.");
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function getProfileIdFromUrl() {
const match = window.location.href.match(/profiles\.php\?XID=(\d+)/);
return match ? match[1] : null;
}
function findGeneralInformationList() {
// The box's own id looks dynamic/unstable (e.g. "item18534331" -
// confirmed live via devtools screenshot), so it's found by its
// h5.box-title text instead.
const headers = document.querySelectorAll(".box-title");
for (const header of headers) {
if (header.textContent.trim() !== "General Information") continue;
const box = header.closest(".sortable-box");
const list = box && box.querySelector("ul.info-cont-wrap");
if (list) return list;
}
return null;
}
// Mirrors Torn's own box shape (title bar + ul.info-cont-wrap), so it
// inherits Torn's own styling instead of needing any of our own -
// structure confirmed live via devtools against the General
// Information / Equipped Weapons & Armor boxes' own markup, including
// the arrow-wrap/move-wrap icon pair. Inserted directly after
// anchorElement (afterend), so callers control placement by choosing
// what to anchor to - e.g. General Information for the first box in the
// column, or a previously-created box's own element to stack a second
// one directly below it. Returns the box's own element and its
// ul.info-cont-wrap list (rows go into the latter).
function createInfoBox(anchorElement, title) {
const box = document.createElement("div");
box.className = "sortable-box t-blue-cont h";
const titleBar = document.createElement("div");
titleBar.className = "title main-title title-black active top-round";
titleBar.setAttribute("role", "table");
const arrowWrap = document.createElement("div");
arrowWrap.className = "arrow-wrap";
const arrowLink = document.createElement("a");
arrowLink.href = "#/";
arrowLink.setAttribute("role", "button");
arrowLink.className = "accordion-header-arrow right";
arrowLink.tabIndex = 0;
arrowWrap.appendChild(arrowLink);
const moveWrap = document.createElement("div");
moveWrap.className = "move-wrap";
moveWrap.setAttribute("data-prevent-flyout-swipe", "true");
const moveIcon = document.createElement("i");
moveIcon.className = "accordion-header-move right";
moveWrap.appendChild(moveIcon);
const heading = document.createElement("h5");
heading.className = "box-title";
heading.textContent = title;
heading.tabIndex = 0;
titleBar.appendChild(arrowWrap);
titleBar.appendChild(moveWrap);
titleBar.appendChild(heading);
const bottomRound = document.createElement("div");
bottomRound.className = "bottom-round";
const contGray = document.createElement("div");
contGray.className = "cont-gray bottom-round";
const list = document.createElement("ul");
list.className = "info-cont-wrap";
contGray.appendChild(list);
bottomRound.appendChild(contGray);
// Torn wires its own collapse handler at page load, so a box injected
// afterward wouldn't pick that up - replicate it ourselves instead,
// toggling the same "active" class + bottom-round visibility Torn uses
// (confirmed via devtools: title-black.active pairs with
// bottom-round { display: block }).
arrowLink.setAttribute("aria-label", `Close ${title} panel`);
arrowLink.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const collapsed = titleBar.classList.toggle("active") === false;
bottomRound.style.display = collapsed ? "none" : "block";
arrowLink.setAttribute("aria-label", `${collapsed ? "Open" : "Close"} ${title} panel`);
});
box.appendChild(titleBar);
box.appendChild(bottomRound);
anchorElement.insertAdjacentElement("afterend", box);
return { box, list };
}
let reviveTrackerBoxShown = false;
function createReviveTrackerBox(generalInfoList) {
if (reviveTrackerBoxShown) return null;
const generalInfoBox = generalInfoList.closest(".sortable-box");
if (!generalInfoBox) return null;
reviveTrackerBoxShown = true;
// Placed directly after General Information, in the same column, so it
// reads as a natural extension of that area rather than appearing
// somewhere unrelated.
return createInfoBox(generalInfoBox, "Revive Tracker");
}
let unpaidRevivesBoxShown = false;
// Its own box (title bar reads "Pending Payment Reminders"), stacked
// directly below the Revive Tracker box rather than folded into it as
// more rows - a distinct, self-contained action list reads more clearly
// as its own thing than as an extension of the stats box above it.
function createUnpaidRevivesBox(reviveTrackerBoxElement) {
if (unpaidRevivesBoxShown) return null;
unpaidRevivesBoxShown = true;
return createInfoBox(reviveTrackerBoxElement, "Pending Payment Reminders");
}
function appendInfoRow(list, label, value) {
const row = document.createElement("li");
row.tabIndex = 0;
// Same two-column shape as Torn's own rows (confirmed live via
// devtools - e.g. "Money" in .divider, "$202,148" in .desc): .divider
// holds the label, .desc holds the value, so this inherits Torn's own
// column styling instead of needing any of our own - except bold,
// which Torn's own label column turned out not to apply on its own
// (confirmed live), so it's set explicitly here to match the rest of
// the table's look.
const divider = document.createElement("span");
divider.className = "divider";
divider.style.fontWeight = "bold";
divider.textContent = label;
const desc = document.createElement("span");
desc.className = "desc";
if (value instanceof Node) {
desc.appendChild(value);
} else {
desc.textContent = value;
}
row.appendChild(divider);
row.appendChild(desc);
// Inserted after the last real <li>, not just appended to the list -
// #liveNetworth (a non-li child) trails the last row in this box,
// confirmed live via devtools screenshot.
const rows = list.querySelectorAll(":scope > li");
const lastRow = rows[rows.length - 1];
if (lastRow) {
lastRow.insertAdjacentElement("afterend", row);
} else {
list.appendChild(row);
}
}
function renderSubscriptionRow(list, subscriptionExpiresAt, subscriptionSource) {
// null for the till account - it's exempt from the gate (see
// get_reviewer_status.py). Still shown as its own row (rather than
// omitted, which is what silently dropped every subscription row for
// the till account's own home page - confirmed live via screenshot),
// just with no expiry date row to go with it since there isn't one.
if (!subscriptionExpiresAt) {
appendInfoRow(list, "Subscription", "Exempt (till account)");
return;
}
// Reflects whichever subscription is actually granting access - a
// reviewer covered only by their faction's subscription would
// otherwise see their own (possibly long-expired) personal trial here
// instead, which is misleading since they have full access. See
// subscriptions.active_subscription.
const viaFaction = subscriptionSource === "faction" ? " (faction)" : "";
const remaining = subscriptionExpiresAt - Math.floor(Date.now() / 1000);
const expired = remaining <= 0;
const days = Math.max(1, Math.ceil(remaining / (24 * 60 * 60)));
appendInfoRow(
list,
"Subscription",
(expired ? "Expired" : `${days} day${days === 1 ? "" : "s"} left`) + viaFaction
);
const expiryDate = new Date(subscriptionExpiresAt * 1000).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
});
appendInfoRow(list, "Subscription Expiry", expiryDate);
}
function renderReviewerStatsRows(list, status) {
appendInfoRow(list, "Total Revives", String(status.total_revives));
appendInfoRow(list, "Pending", String(status.pending_revives));
appendInfoRow(
list,
"Paid",
status.pay_rate === null ? String(status.paid_revives) : `${status.paid_revives} (${status.pay_rate}%)`
);
appendInfoRow(list, "Contract", String(status.contract_revives));
}
// Byte-for-byte the same link Torn's own "Send Message" button on a
// profile page uses - confirmed live via devtools (see
// diagnostic_images/): <a href="/messages.php#/p=compose&XID=2749499">,
// no query string at all. Our own revive_id/target_username payload
// travels out-of-band via GM storage instead (see
// storePendingPaymentRequest/consumePendingPaymentRequest) specifically
// so this link is indistinguishable from a native one - if Torn's
// compose page does anything special when it sees XID that a query
// string might interfere with, this avoids that risk entirely.
function buildPaymentRequestLink(revive) {
return `https://www.torn.com/messages.php#/p=compose&XID=${revive.target_id}`;
}
// Single global slot rather than a list - only ever one pending
// navigation at a time (the reviewer just clicked a link), consumed
// (read once, then cleared) by the compose page that follows it. Stored
// as a JSON string rather than a raw object for portability across
// GM_setValue shims that may only round-trip strings faithfully (see
// TornPDA compatibility notes above).
const PENDING_PAYMENT_REQUEST_KEY = "pending_payment_request";
// kind picks which subject/message injectComposePagePrefill fills in -
// "reminder" (default, the existing 12h-unpaid nudge) or "thank_you" (the
// immediate post-revive prompt, see showThankYouPromptToast). Both land
// on the exact same compose page, but only "reminder" marks
// payment_request_sent_at once sent (see injectComposePagePrefill) -
// sending an immediate thank-you note deliberately doesn't suppress the
// later reminder, since the target may still not have paid by then.
function storePendingPaymentRequest(revive, kind) {
GM_setValue(
PENDING_PAYMENT_REQUEST_KEY,
JSON.stringify({ reviveId: revive.revive_id, targetUsername: revive.target_username || null, kind: kind || "reminder" })
);
}
function consumePendingPaymentRequest() {
const raw = GM_getValue(PENDING_PAYMENT_REQUEST_KEY, null);
GM_setValue(PENDING_PAYMENT_REQUEST_KEY, null);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (err) {
return null;
}
}
// Capped rather than showing every unpaid revive ever - a heavy reviewer
// can accumulate dozens of these, and the homepage box isn't the place
// for an unbounded list. The backend already returns oldest-unpaid-first
// (see get_unpaid_revives.py), so this keeps the ones most worth chasing.
const MAX_UNPAID_REVIVES_SHOWN = 10;
// Caller is expected to only create the box (and call this) when
// unpaidRevives is non-empty - the "Pending Payment Reminders" title
// now lives on that box's own title bar, not a row inside it, so an
// empty list has nothing to title in the first place.
function renderUnpaidRevivesRows(list, unpaidRevives) {
const shown = unpaidRevives.slice(0, MAX_UNPAID_REVIVES_SHOWN);
for (const revive of shown) {
const label = revive.target_username || `Player #${revive.target_id}`;
if (revive.payment_request_sent) {
appendInfoRow(list, label, `Requested (${revive.hours_unpaid}h unpaid)`);
continue;
}
const link = document.createElement("a");
link.href = buildPaymentRequestLink(revive);
link.textContent = `Send payment request (${revive.hours_unpaid}h unpaid)`;
// Runs synchronously before the browser follows the link, same tick
// as the click - the compose page that loads next reads this back
// via consumePendingPaymentRequest.
link.addEventListener("click", () => storePendingPaymentRequest(revive, "reminder"));
appendInfoRow(list, label, link);
}
if (unpaidRevives.length > MAX_UNPAID_REVIVES_SHOWN) {
appendInfoRow(list, "", `+${unpaidRevives.length - MAX_UNPAID_REVIVES_SHOWN} more unpaid`);
}
}
function renderUpdateRow(list, latestVersion) {
const link = document.createElement("a");
link.href = GREASYFORK_SCRIPT_URL;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = `v${latestVersion} on Greasy Fork`;
appendInfoRow(list, "Update Available", link);
}
// Shown in the free stats box when a lapsed/free-tier reviewer's
// payment-reminders lookup comes back 402 (nudges are a paid feature).
// A single, targeted upsell row right where the reminders would otherwise
// appear - deliberately not the full-width red banner (showNotice), which
// is reserved for reaching a hard paywall like another player's reputation.
// Personal stats above stay free and un-nagged.
function renderNudgeUpsellRow(list) {
const link = document.createElement("a");
link.href = `https://www.torn.com/profiles.php?XID=${TILL_PROFILE_ID}`;
link.target = "_blank";
link.rel = "noopener";
link.textContent = "Subscribe to chase unpaid revives";
appendInfoRow(list, "Payment Reminders", link);
}
// Formats a payment response time (avg_response_seconds from the backend
// - a mean over every paid revive for a target, see models.Reputation)
// as a short human string - "45m", "2h", "3d" - rather than raw seconds.
function formatDuration(seconds) {
if (seconds < 60) return `${Math.round(seconds)}s`;
const minutes = seconds / 60;
if (minutes < 60) return `${Math.round(minutes)}m`;
const hours = minutes / 60;
if (hours < 24) return `${Math.round(hours * 10) / 10}h`;
const days = hours / 24;
return `${Math.round(days * 10) / 10}d`;
}
function renderNameBadge(nameElement, reputation) {
const ratio =
reputation.total_count > 0
? Math.round((100 * reputation.paid_count) / reputation.total_count)
: null;
const badge = document.createElement("div");
badge.style.cssText =
"margin-left:8px;padding:6px 10px;border-radius:4px;font-size:10px;" +
"font-weight:bold;display:inline-block;vertical-align:middle;" +
(ratio === null
? "background:#666;color:#fff;"
: ratio >= 70
? "background:#2e7d32;color:#fff;"
: ratio >= 30
? "background:#f9a825;color:#000;"
: "background:#c62828;color:#fff;");
// avg_response_seconds is subscriber-only data (the endpoints it comes
// from are gated behind an active subscription already - see
// apiRequest/authorize_reviewer), so no separate check is needed here;
// it's simply absent/null for a target with no paid revives yet.
const speedSuffix =
ratio !== null && reputation.avg_response_seconds != null
? `, avg ${formatDuration(reputation.avg_response_seconds)}`
: "";
badge.textContent =
ratio === null
? "Revive Tracker: no data yet"
: `Revive Tracker: pays ${ratio}% of the time (${reputation.paid_count}/${reputation.total_count})${speedSuffix}`;
// Appended as a child of the name heading, not a sibling in
// .content-title - confirmed live (screenshot) that container is a
// float layout (icon list/name/links-top-wrap all float:left), and the
// hospital-row badge incident showed inserting a new sibling into an
// existing row can shift everything after it. A child only grows the
// h4's own box; display:inline-block keeps this div on the same line
// as the name text instead of dropping to a new line.
nameElement.appendChild(badge);
}
function injectReputationBadge() {
const targetId = getProfileIdFromUrl();
if (!targetId) return;
// Fired immediately at document-start - only needs the URL, not the DOM
// - so the network round-trip runs in parallel with page parsing rather
// than waiting for the name heading to exist first. The .catch here is
// just to prevent an unhandled-rejection warning if this settles before
// waitForElement's callback below awaits it; the real error handling
// happens there.
const reputationPromise = apiRequest("GET", `/reputation/${targetId}`);
reputationPromise.catch(() => {});
// #skip-to-content is the profile name heading - it's the accessibility
// skip-navigation landmark target (confirmed live via devtools), which
// is a more stable anchor than a page-specific class. Falls back to the
// page-title heading inside .content-title in case that ID ever moves.
waitForElement("h4#skip-to-content, .content-title h4", async (nameElement) => {
console.info("[Revive Tracker] profile name heading found, awaiting reputation lookup");
try {
const reputation = await reputationPromise;
renderNameBadge(nameElement, reputation);
console.info("[Revive Tracker] profile reputation badge rendered", reputation);
} catch (err) {
console.error("[Revive Tracker] profile reputation lookup failed", err);
handleReputationFetchError(err);
}
});
}
function injectHomePageInfo() {
// Fired immediately for the same reason as injectReputationBadge above -
// needs no DOM, so it runs in parallel with page parsing.
const statusPromise = apiRequest("GET", "/reviewer/status");
statusPromise.catch(() => {});
const unpaidRevivesPromise = apiRequest("GET", "/reviewer/unpaid-revives");
unpaidRevivesPromise.catch(() => {});
// Shown as rows in their own "Revive Tracker" box (placed right after
// General Information, mirroring Torn's own box shape) rather than
// badges - badges are reserved for the hospital list and other
// players' profile pages.
waitForCondition(findGeneralInformationList, async (generalInfoList) => {
const reviveTrackerBox = createReviveTrackerBox(generalInfoList);
if (!reviveTrackerBox) return;
const { box: reviveTrackerBoxElement, list } = reviveTrackerBox;
console.info("[Revive Tracker] Revive Tracker box created, awaiting /reviewer/status");
try {
const status = await statusPromise;
console.info("[Revive Tracker] /reviewer/status resolved", status);
renderSubscriptionRow(list, status.subscription_expires_at, status.subscription_source);
renderReviewerStatsRows(list, status);
} catch (err) {
console.error("[Revive Tracker] /reviewer/status failed", err);
handleReputationFetchError(err);
}
// Payment nudges stay a paid feature (unlike the personal stats above,
// which are now free - see get_reviewer_status). Its own box, stacked
// below Revive Tracker, only created once there's something to nudge.
// A 402 here means a lapsed/free-tier reviewer - rather than nag on the
// free stats, surface one targeted upsell row where the reminders would
// appear (see renderNudgeUpsellRow).
try {
const { unpaid_revives: unpaidRevives } = await unpaidRevivesPromise;
if (unpaidRevives.length > 0) {
const unpaidBox = createUnpaidRevivesBox(reviveTrackerBoxElement);
if (unpaidBox) renderUnpaidRevivesRows(unpaidBox.list, unpaidRevives);
}
} catch (err) {
if (err.status === 402) {
renderNudgeUpsellRow(list);
} else {
console.error("[Revive Tracker] /reviewer/unpaid-revives failed", err);
}
}
// Independent of reviewer status above - shown either way.
const latestVersion = await updateCheckPromise;
if (latestVersion) {
renderUpdateRow(list, latestVersion);
}
});
}
const BADGED_NAME_SPANS = new WeakSet();
function renderInlineBadge(afterElement, reputation) {
const ratio =
reputation.total_count > 0
? Math.round((100 * reputation.paid_count) / reputation.total_count)
: null;
const badge = document.createElement("span");
badge.style.cssText =
"margin-left:6px;padding:1px 6px;border-radius:3px;font-size:10px;" +
"font-weight:bold;display:inline-block;vertical-align:middle;" +
(ratio === null
? "background:#666;color:#fff;"
: ratio >= 70
? "background:#2e7d32;color:#fff;"
: ratio >= 30
? "background:#f9a825;color:#000;"
: "background:#c62828;color:#fff;");
badge.textContent = ratio === null ? "no data" : `${ratio}% pay`;
// Row space is too tight for a visible speed suffix here (unlike the
// profile badge) - surfaced as a hover tooltip instead.
if (ratio !== null && reputation.avg_response_seconds != null) {
badge.title = `Avg response time: ${formatDuration(reputation.avg_response_seconds)}`;
}
// Appended *inside* the name span (not as a sibling after it) - Torn's
// hospital row is a flex row with positional/implicit columns, so an
// extra top-level sibling shifted every column after it (Time/Level/
// Reason misaligned, revive button broken for that row - confirmed live
// via screenshot). A child of the name span doesn't affect row layout.
afterElement.appendChild(badge);
}
function hasRevivesOn(nameSpan) {
const row = nameSpan.closest("li");
const reviveLink = row && row.querySelector("a.revive");
// "reviveNotAvailable" on the row's revive button is the only DOM signal
// for "this player can't be revived by you right now" - assumed to
// reflect their revive-privacy setting being off (rather than e.g. a
// level restriction or cooldown, which weren't distinguished live).
return !!reviveLink && !reviveLink.classList.contains("reviveNotAvailable");
}
// Rows queued for the next batch fetch, keyed by target id (a hospital
// list page can have >1 row for the same player across pagination
// reloads, so this dedupes the outgoing request too).
let pendingRowBadges = new Map();
let pendingBatchTimer = null;
// Debounce window before firing a batch request - long enough to catch
// every row from a single page load/pagination reload (which all arrive
// as one burst of DOM mutations), short enough that it's imperceptible.
const BATCH_DEBOUNCE_MS = 50;
function queueRowBadge(nameSpan) {
if (BADGED_NAME_SPANS.has(nameSpan)) return;
if (!hasRevivesOn(nameSpan)) return;
const link = nameSpan.querySelector('a[href*="profiles.php?XID="]');
if (!link) return;
const match = link.getAttribute("href").match(PROFILE_ID_PATTERN);
if (!match) return;
BADGED_NAME_SPANS.add(nameSpan);
const targetId = match[1];
if (!pendingRowBadges.has(targetId)) pendingRowBadges.set(targetId, []);
pendingRowBadges.get(targetId).push(nameSpan);
if (pendingBatchTimer) clearTimeout(pendingBatchTimer);
pendingBatchTimer = setTimeout(flushRowBadges, BATCH_DEBOUNCE_MS);
}
async function flushRowBadges() {
pendingBatchTimer = null;
if (pendingRowBadges.size === 0) return;
const batch = pendingRowBadges;
pendingRowBadges = new Map();
// One request covers every row queued during the debounce window,
// instead of one request (and one key/subscription re-check) per row.
console.info("[Revive Tracker] hospital list batch request", { count: batch.size });
try {
const response = await apiRequest("POST", "/reputation/batch", {
target_ids: Array.from(batch.keys()).map((id) => parseInt(id, 10)),
});
let rendered = 0;
for (const [targetId, nameSpans] of batch) {
const reputation = response.items[targetId];
if (!reputation) continue;
for (const nameSpan of nameSpans) {
renderInlineBadge(nameSpan, reputation);
rendered++;
}
}
console.info("[Revive Tracker] hospital list badges rendered", { rendered });
} catch (err) {
console.error("[Revive Tracker] hospital list batch lookup failed", err);
handleReputationFetchError(err);
}
}
function injectHospitalBadges() {
document.querySelectorAll(".user-wrap.user-name").forEach(queueRowBadge);
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.matches(".user-wrap.user-name")) {
queueRowBadge(node);
}
node.querySelectorAll(".user-wrap.user-name").forEach(queueRowBadge);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function isMessageComposePage() {
// A pending payload in GM storage (set by storePendingPaymentRequest
// just before navigating here) is what distinguishes "compose opened
// from our unpaid-revives list" from any other visit to messages.php -
// without it, this page-flag stays off and none of the
// prefill/send-tracking logic below runs. Just a peek (not consumed)
// here - the real read-and-clear happens once in
// injectComposePagePrefill via consumePendingPaymentRequest.
return /messages\.php/.test(window.location.href) && !!GM_getValue(PENDING_PAYMENT_REQUEST_KEY, null);
}
// Firm but not demanding: states the norm as a plain fact (this is what
// reviving costs, this is what tracking it does) rather than a request
// ("please") or a demand ("must") - and doubles as low-key advertising
// by linking the tool itself, since the target may not have it yet.
const MESSAGE_SUBJECT = "Revive payment";
// \n\n between each point so setComposeMessageBody renders them as
// separate paragraphs (blank line between) rather than one dense block.
const PAYMENT_REQUEST_MESSAGE =
`You were revived earlier. This revive was tracked by Revive Payment Tracker (${GREASYFORK_SCRIPT_URL}) - a tool that shows revivers whether the people they revive actually pay them afterward.\n\n` +
`The standard thank-you for a revive is 1 Xanax and I would very much appreciate a donation towards the cost of energy spent reviving you.\n\n` +
`Paying also keeps your own record looking good for the next reviver considering reviving you.`;
// Sent right after a revive, not 12h later - warmer/thankful rather than
// firm, since nobody's overdue yet at this point. Same payment norm
// stated as the same plain fact, just framed as a thank-you-in-advance
// instead of a follow-up. See showThankYouPromptToast.
const THANK_YOU_SUBJECT = "Thanks for the revive!";
const THANK_YOU_MESSAGE =
`Hey - I just revived you, hope that helped.\n\n` +
`No pressure at all, but the standard courtesy for a revive is 1 Xanax, sent whenever you're able - genuinely appreciated if you do. Tracked with Revive Payment Tracker (${GREASYFORK_SCRIPT_URL}), which is also how I knew to send this.\n\n` +
`Thanks in advance, and take care out there!`;
function setFieldValue(field, value) {
field.value = value;
// The recipient field is a jQuery UI autocomplete
// (ui-autocomplete-input, confirmed live via devtools) that does a
// live AJAX search as you type - dispatching both input and keyup
// covers widgets that listen to either. Confirmed live that typing
// alone (even with these events) does NOT register as a real
// selection - the reviewer still had to pick the suggestion from the
// dropdown by hand - so this is only step one; see
// selectAutocompleteSuggestion for the part that actually picks it.
field.dispatchEvent(new Event("input", { bubbles: true }));
field.dispatchEvent(new Event("keyup", { bubbles: true }));
field.dispatchEvent(new Event("change", { bubbles: true }));
}
// Looks for the rendered suggestion list after typing into the recipient
// field - #ac-search-0-cont is the widget's own results container
// (confirmed live via devtools), with a couple of generic jQuery UI
// fallbacks in case Torn ever changes that id. Returns the list of
// candidate items (li/a - exact tag not confirmed, since the dropdown's
// contents weren't expanded in the devtools snapshot this was checked
// against) or null if nothing's rendered yet.
// Confirmed live (screenshot): the results dropdown is scoped by a row
// of tabs - "Friends", "Faction", "Company", "All" - with "Friends"
// active by default. That default is exactly why selection was failing:
// the till account and a typical revive target aren't friends,
// faction-mates, or company-mates, so the default tab shows "No person
// could be found" every time. "All" is the one that actually searches
// every player.
const RECIPIENT_TAB_LABELS = ["friends", "faction", "company", "all"];
const NO_RESULTS_TEXT = "no person could be found";
function findRecipientAllTab() {
const candidates = document.querySelectorAll('a, li, button, [role="tab"]');
return Array.from(candidates).find((el) => (el.textContent || "").trim().toLowerCase() === "all") || null;
}
function clickRecipientAllTab(allTab) {
["mousedown", "click"].forEach((type) => {
allTab.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
});
}
// Excludes the tab labels themselves and the "no results" placeholder
// text - both matched the old, broader selector and could otherwise get
// clicked as if they were a real person (most likely explanation for
// "selection isn't working": with a target that doesn't match any tab
// label, the old code's items[0] fallback would have landed on the
// first tab rather than an actual result).
function findAutocompleteSuggestions() {
const container = document.querySelector("#ac-search-0-cont, .autocomplete-wrap, .ui-autocomplete");
if (!container) return null;
const items = Array.from(container.querySelectorAll("li, a")).filter((el) => {
const text = (el.textContent || "").trim().toLowerCase();
return text && !RECIPIENT_TAB_LABELS.includes(text) && text !== NO_RESULTS_TEXT;
});
return items.length > 0 ? items : null;
}
// Picks the suggestion whose text matches the target username (falling
// back to the first result if none match exactly - searching by their
// exact username should make the first hit correct regardless) and
// dispatches mousedown before click: stock jQuery UI Autocomplete binds
// selection to mousedown specifically, so the input's blur doesn't
// cancel the selection before a click would register. Firing both
// covers Torn's implementation whether or not it's customized from the
// jQuery UI default.
function selectAutocompleteSuggestion(items, targetUsername) {
const lowerTarget = (targetUsername || "").toLowerCase();
const match =
Array.from(items).find((el) => (el.textContent || "").toLowerCase().includes(lowerTarget)) || items[0];
["mousedown", "click"].forEach((type) => {
match.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
});
}
// Message body is a rich-text editor (TinyMCE, confirmed live via
// devtools - mce-content-body/mce_0 are TinyMCE's own conventions), not
// a plain <textarea>: the visible, editable surface is a contenteditable
// div. There's also a genuine <textarea> in the DOM (class sourceArea),
// but it's hidden (TinyMCE's own "source view" mirror) and isn't what
// gets read on submit - writing to it would silently do nothing.
function setComposeMessageBody(value) {
// #mce_0 is TinyMCE's actual id on the one live compose form checked
// (see diagnostic_images/) - the class-based fallback covers it
// shifting if Torn ever mounts more than one editor on this page.
const editor = document.querySelector("#mce_0, .editor-content[contenteditable='true'], [contenteditable='true']");
if (!editor) return false;
// Plain textContent collapses \n under normal CSS whitespace rules -
// a contenteditable surface needs real <br> nodes to show line breaks.
// Built via DOM calls (not innerHTML) so nothing in value is ever
// parsed as markup.
editor.textContent = "";
value.split("\n").forEach((line, i) => {
if (i > 0) editor.appendChild(document.createElement("br"));
editor.appendChild(document.createTextNode(line));
});
// contenteditable elements are driven by input events, not
// value/change - TinyMCE listens for this to sync its hidden mirror
// field before the form submits.
editor.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
// Confirmed live via devtools (see diagnostic_images/): the recipient
// field is <input name="sendto" id="ac-search-0" class="...
// ui-autocomplete-input" placeholder="Name">, and the message body is a
// TinyMCE contenteditable div (see setComposeMessageBody). Also
// confirmed live (real test run): typing into #sendto alone isn't
// enough - the reviewer still had to pick the suggestion from the
// dropdown by hand for it to register as a real recipient, so this now
// also simulates that pick (see findAutocompleteSuggestions/
// selectAutocompleteSuggestion) once the AJAX search has rendered
// results. What's still genuinely uncertain:
// - The exact suggestion markup (li vs a, structure) wasn't visible in
// the devtools snapshot this was built against (the dropdown wasn't
// expanded), so the selector there is broader/more speculative than
// the rest of this function.
// - Detecting an actual send: rather than guessing one exact button
// selector, this listens broadly for a form submit or a click on
// anything whose visible text looks like a send action (the real
// button reads "SEND"), so it's more likely to fire even if the
// exact markup differs - at the cost of firing on the intent to
// send, not a confirmed delivery (see mark_payment_request_sent.py's
// docstring for why that's an accepted tradeoff here).
function injectComposePagePrefill() {
const pending = consumePendingPaymentRequest();
if (!pending) return;
const { reviveId, targetUsername, kind } = pending;
const isThankYou = kind === "thank_you";
const subject = isThankYou ? THANK_YOU_SUBJECT : MESSAGE_SUBJECT;
const message = isThankYou ? THANK_YOU_MESSAGE : PAYMENT_REQUEST_MESSAGE;
waitForElement("#mce_0, .editor-content[contenteditable='true'], [contenteditable='true']", () => {
setComposeMessageBody(message);
console.info("[Revive Tracker] Prefilled payment-request message body");
});
// UNVERIFIED LIVE - unlike the recipient/message-body selectors, the
// subject field's own devtools panel was never expanded in the one
// snapshot this was built against (see diagnostic_images/), so this is
// a guess: a plain text input, sibling to the recipient's own
// form-title-input-text wrapper, most likely named/placeholder'd
// "subject" by analogy with sendto/placeholder="Name" on that field.
waitForElement('input[name="subject" i], input[placeholder="Subject" i], input[name="title" i]', (subjectField) => {
setFieldValue(subjectField, subject);
console.info("[Revive Tracker] Prefilled payment-request subject");
});
if (targetUsername) {
waitForElement('input[name="sendto"], #ac-search-0', (recipientInput) => {
// Now that the link matches Torn's own native "Send Message" href
// exactly (see buildPaymentRequestLink), Torn's own compose page
// may already resolve the recipient from XID by itself before this
// even runs - if so, defer to it entirely rather than clobbering a
// correct native selection with a redundant manual search.
if ((recipientInput.value || "").trim().toLowerCase() === targetUsername.toLowerCase()) {
console.info("[Revive Tracker] Recipient already preselected natively - skipping manual search");
return;
}
setFieldValue(recipientInput, targetUsername);
// Wait for the tab row to render, then switch off the default
// "Friends" scope to "All" before looking for a real result - see
// findRecipientAllTab's comment for why the default tab almost
// never finds a typical revive target.
waitForCondition(findRecipientAllTab, (allTab) => {
clickRecipientAllTab(allTab);
console.info("[Revive Tracker] Switched recipient search to the All tab");
waitForCondition(findAutocompleteSuggestions, (items) => {
selectAutocompleteSuggestion(items, targetUsername);
console.info("[Revive Tracker] Auto-selected recipient from autocomplete dropdown");
});
});
});
}
// Deliberately NOT wired up for the thank-you flow: sending an
// immediate thank-you note shouldn't set payment_request_sent_at, so a
// revive that gets one can still surface in Pending Payment Reminders
// later if it's still unpaid after 12h - the two are independent nudges
// now, not mutually exclusive.
if (isThankYou) return;
let marked = false;
const markSentOnce = () => {
if (marked) return;
marked = true;
apiRequest("POST", `/revives/${encodeURIComponent(reviveId)}/payment-request`)
.then(() => console.info("[Revive Tracker] Marked payment request as sent", reviveId))
.catch((err) => console.error("[Revive Tracker] Failed to mark payment request as sent", err));
};
document.addEventListener("submit", markSentOnce, true);
document.addEventListener(
"click",
(event) => {
const el = event.target.closest && event.target.closest('button, [type="submit"], a');
if (!el) return;
const text = (el.textContent || "").trim().toLowerCase();
if (text === "send" || text.includes("send message")) markSentOnce();
},
true
);
}
function isProfilePage() {
return /profiles\.php/.test(window.location.href);
}
function isHospitalListPage() {
return /hospitalview/.test(window.location.href);
}
function isHospitalOrAttackPage() {
return /(hospitalview|loader\.php\?sid=attack)/.test(window.location.href);
}
function isHomePage() {
const path = window.location.pathname;
return path === "/" || path === "/index.php";
}
console.info("[Revive Tracker] page flags", {
hospitalOrAttack: isHospitalOrAttackPage(),
profile: isProfilePage(),
hospitalList: isHospitalListPage(),
home: isHomePage(),
messageCompose: isMessageComposePage(),
});
if (isHospitalOrAttackPage()) {
whenBodyReady(() => {
watchForRevive();
watchForReviveButton();
});
}
if (isProfilePage()) {
injectReputationBadge();
}
if (isHospitalListPage()) {
whenBodyReady(injectHospitalBadges);
}
if (isHomePage()) {
injectHomePageInfo();
}
if (isMessageComposePage()) {
whenBodyReady(injectComposePagePrefill);
}
})();