Torn PDA / Tampermonkey script for viewing and voting on shared trader reputation in Torn
// ==UserScript==
// @name Torn Trade Reputation
// @namespace https://greatest.deepsurf.us/users/1590579
// @version 1.3.0.6
// @description Torn PDA / Tampermonkey script for viewing and voting on shared trader reputation in Torn
// @author Vreebn [4149405]
// @match https://www.torn.com/*
// @match https://torn.com/*
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @connect torn-trade-rep.ebnoreza.workers.dev
// @connect tornexchange.com
// @run-at document-idle
// @license MIT
// @supportURL https://greatest.deepsurf.us/en/scripts/573609-torn-trade-reputation/feedback
// @homepageURL https://greatest.deepsurf.us/en/scripts/573609-torn-trade-reputation
// ==/UserScript==
(function () {
'use strict';
if (window.__TTR_ACTIVE__) return;
window.__TTR_ACTIVE__ = true;
const SCRIPT_VERSION = '1.2.0.6';
const WORKER_BASE = 'https://torn-trade-rep.ebnoreza.workers.dev';
const PDA_KEY = '###PDA-APIKEY###';
const STORAGE_KEY_TORN = 'ttr_torn_api_key';
const STORAGE_KEY_TE = 'ttr_te_api_key';
const STORAGE_KEY_TE_ENABLED = 'ttr_te_enabled';
const STORAGE_KEY_PANEL_STATE = 'ttr_panel_state_v1';
const STORAGE_KEY_LAST_EVENTS_HEAD = 'ttr_last_events_head_ref_v1';
const STORAGE_KEY_SYNCED_EVENT_REFS = 'ttr_synced_event_refs_v1';
const ROUTE_POLL_MS = 1200;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const EVENT_SYNC_DEBOUNCE_MS = 2500;
const MAX_SYNCED_EVENT_REFS = 1200;
const SUSPICIOUS_MUG_LINK_WINDOW_SEC = 10 * 60;
const NINETY_DAYS_SEC = 90 * 24 * 60 * 60;
const CACHE_KEYS = {
SELF_PROFILE: 'ttr_cache_self_profile_v1',
PRIVATE_PROFILE: 'ttr_cache_private_profile_v1',
SUPPLEMENTAL: 'ttr_cache_supplemental_v1',
RESOLVE_USER: 'ttr_cache_resolve_user_v1'
};
const MUG_SUBTYPES = {
after_market_purchase: 'After market purchase',
after_cancelled_trade: 'After cancelled trade',
other_trade_related_mug: 'Other trade-related mug'
};
let currentRouteKey = '';
let currentTargetId = null;
let currentTargetName = '';
let currentTradeId = null;
let currentContextType = null;
let selfProfileCache = null;
let currentViewerVote = null;
let currentViewerMugReport = null;
let rootEl = null;
let bodyEl = null;
let collapsedSummaryEl = null;
let mugRowEl = null;
let nameEl = null;
let scoreEl = null;
let myVoteEl = null;
let mugReportsEl = null;
let mugTopReasonEl = null;
let statusEl = null;
let btnUp = null;
let btnDown = null;
let actionsEl = null;
let friendEnemyEl = null;
let teEl = null;
let tradeHistoryEl = null;
let settingsBtn = null;
let selfVotesBtn = null;
let riskLabelEl = null;
let currentBountyEl = null;
let mug90dEl = null;
let collapseBtn = null;
let lastEventsSyncAt = 0;
let syncedEventRefs = loadSyncedEventRefs();
let panelState = loadPanelState();
init().catch((err) => {
console.error('[TTR] Init error:', err);
});
async function init() {
injectStyles();
installRouteHooks();
if (!getEffectiveTornApiKey()) {
openTornKeyPrompt();
}
await refreshForCurrentRoute();
scheduleEventsSync(true);
setInterval(() => {
refreshForCurrentRoute().catch((err) => {
console.error('[TTR] Route refresh error:', err);
});
scheduleEventsSync(false);
}, ROUTE_POLL_MS);
}
function injectStyles() {
if (document.getElementById('ttr-style')) return;
const style = document.createElement('style');
style.id = 'ttr-style';
style.textContent = `
.ttr-inline-card {
display: block !important;
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
clear: both !important;
margin: 6px 0 10px 0 !important;
padding: 10px 12px !important;
border: 1px solid rgba(255,255,255,0.10) !important;
border-radius: 4px !important;
background: rgba(0,0,0,0.18) !important;
font-size: 12px !important;
line-height: 1.35 !important;
overflow: hidden !important;
}
.ttr-inline-card.ttr-tone-safe {
background: linear-gradient(180deg, rgba(0,0,0,0.25) 0%, rgba(14,64,28,0.48) 100%) !important;
border-color: rgba(111,226,111,0.18) !important;
}
.ttr-inline-card.ttr-tone-neutral {
background: linear-gradient(180deg, rgba(0,0,0,0.25) 0%, rgba(78,62,18,0.42) 100%) !important;
border-color: rgba(255,215,90,0.16) !important;
}
.ttr-inline-card.ttr-tone-risky {
background: linear-gradient(180deg, rgba(0,0,0,0.25) 0%, rgba(88,18,18,0.50) 100%) !important;
border-color: rgba(255,123,123,0.18) !important;
}
.ttr-inline-card.collapsed {
padding: 8px 10px !important;
}
.ttr-inline-card.collapsed #ttr-header {
grid-template-columns: minmax(0,1fr) auto !important;
gap: 4px !important;
align-items: center !important;
}
.ttr-inline-card.collapsed .ttr-first-left,
.ttr-inline-card.collapsed .ttr-first-center {
display: none !important;
}
.ttr-inline-card.collapsed .ttr-first-right {
width: auto !important;
justify-self: end !important;
}
.ttr-inline-card.collapsed #ttr-settings {
display: none !important;
}
.ttr-inline-card.collapsed #ttr-body {
display: none !important;
}
.ttr-inline-card.collapsed #ttr-collapsed-summary {
display: block !important;
margin-top: 6px !important;
}
.ttr-mount-row {
display: block !important;
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
clear: both !important;
}
.ttr-inline-main {
display: grid !important;
grid-template-columns: minmax(0,1fr) auto minmax(0,1fr) !important;
align-items: center !important;
gap: 8px !important;
width: 100% !important;
min-width: 0 !important;
}
.ttr-first-left {
text-align: left !important;
min-width: 0 !important;
overflow: hidden !important;
white-space: nowrap !important;
text-overflow: ellipsis !important;
justify-self: start !important;
}
.ttr-first-center {
text-align: center !important;
min-width: 0 !important;
white-space: nowrap !important;
font-weight: 700 !important;
opacity: 0.96 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
justify-self: center !important;
}
.ttr-first-right {
text-align: right !important;
min-width: 0 !important;
justify-self: end !important;
width: 100% !important;
}
.ttr-inline-top-right {
display: inline-flex !important;
width: auto !important;
justify-content: flex-end !important;
align-items: center !important;
gap: 2px !important;
margin-left: auto !important;
}
.ttr-inline-reason-row {
display: flex !important;
justify-content: center !important;
align-items: center !important;
gap: 8px !important;
width: 100% !important;
text-align: center !important;
margin-top: 8px !important;
opacity: 0.88 !important;
font-weight: 700 !important;
min-height: 16px !important;
flex-wrap: wrap !important;
}
.ttr-inline-stats-row {
display: grid !important;
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
align-items: center !important;
gap: 6px !important;
width: 100% !important;
margin-top: 8px !important;
}
.ttr-stat-cell {
text-align: center !important;
min-width: 0 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.ttr-inline-label,
.ttr-risk-label {
opacity: 0.78 !important;
margin-right: 4px !important;
}
.ttr-inline-name,
.ttr-inline-score,
.ttr-inline-vote,
.ttr-inline-meta-value,
.ttr-inline-history-value,
.ttr-risk-value {
font-weight: 700 !important;
}
.ttr-inline-name {
display: inline-block !important;
max-width: 100% !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
vertical-align: bottom !important;
}
.ttr-inline-score.pos,
.ttr-inline-vote.pos,
.ttr-inline-meta-value.pos,
.ttr-inline-history-value.pos,
.ttr-risk-value.pos,
.ttr-collapsed-value.pos {
color: #6fe26f !important;
}
.ttr-inline-score.neg,
.ttr-inline-vote.neg,
.ttr-inline-meta-value.neg,
.ttr-inline-history-value.neg,
.ttr-risk-value.neg,
.ttr-collapsed-value.neg {
color: #ff7b7b !important;
}
.ttr-inline-score.neutral,
.ttr-inline-vote.neutral,
.ttr-inline-meta-value.neutral,
.ttr-inline-history-value.neutral,
.ttr-risk-value.neutral,
.ttr-collapsed-value.neutral {
color: #f3f4f6 !important;
opacity: 0.92 !important;
}
.ttr-gear {
appearance: none !important;
-webkit-appearance: none !important;
border: 1px solid rgba(255,255,255,0.20) !important;
background: rgba(255,255,255,0.04) !important;
color: #fff !important;
border-radius: 4px !important;
padding: 3px 7px !important;
font-size: 12px !important;
cursor: pointer !important;
line-height: 1 !important;
box-sizing: border-box !important;
}
.ttr-collapsed-summary {
display: none !important;
width: 100% !important;
white-space: normal !important;
overflow: visible !important;
text-overflow: unset !important;
word-break: break-word !important;
line-height: 1.45 !important;
font-size: 11px !important;
font-weight: 700 !important;
opacity: 0.96 !important;
}
.ttr-collapsed-piece {
display: inline !important;
}
.ttr-collapsed-sep {
opacity: 0.65 !important;
color: #fff !important;
}
.ttr-inline-status {
display: block !important;
opacity: 0.72 !important;
width: 100% !important;
margin-top: 8px !important;
}
.ttr-inline-status.centered {
text-align: center !important;
}
.ttr-inline-actions {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 8px !important;
width: 100% !important;
margin-top: 10px !important;
}
.ttr-inline-actions.hidden {
display: none !important;
}
.ttr-inline-self-row {
display: none !important;
width: 100% !important;
margin-top: 8px !important;
justify-content: center !important;
align-items: center !important;
text-align: center !important;
}
.ttr-inline-self-row.show {
display: flex !important;
}
.ttr-btn,
.ttr-btn:visited,
.ttr-btn:hover,
.ttr-btn:active,
.ttr-btn:focus,
.ttr-self-btn,
.ttr-link-btn,
.ttr-tab-btn,
.ttr-modal-save-top {
appearance: none !important;
-webkit-appearance: none !important;
border-radius: 4px !important;
padding: 7px 8px !important;
font-size: 12px !important;
font-weight: 700 !important;
cursor: pointer !important;
width: 100% !important;
min-width: 0 !important;
border-width: 1px !important;
border-style: solid !important;
box-shadow: none !important;
text-shadow: none !important;
background-image: none !important;
outline: none !important;
text-align: center !important;
box-sizing: border-box !important;
}
.ttr-self-btn {
width: auto !important;
min-width: 150px !important;
margin: 0 auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
.ttr-btn-up,
.ttr-btn-up:visited,
.ttr-btn-up:hover,
.ttr-btn-up:active,
.ttr-btn-up:focus {
color: #6fe26f !important;
background: rgba(255,255,255,0.04) !important;
border-color: rgba(111,226,111,0.45) !important;
}
.ttr-btn-down,
.ttr-btn-down:visited,
.ttr-btn-down:hover,
.ttr-btn-down:active,
.ttr-btn-down:focus {
color: #ff7b7b !important;
background: rgba(255,255,255,0.04) !important;
border-color: rgba(255,123,123,0.45) !important;
}
.ttr-btn-up.active,
.ttr-btn-up.active:visited,
.ttr-btn-up.active:hover,
.ttr-btn-up.active:active,
.ttr-btn-up.active:focus {
background: #2f9e44 !important;
border-color: #2f9e44 !important;
color: #fff !important;
}
.ttr-btn-down.active,
.ttr-btn-down.active:visited,
.ttr-btn-down.active:hover,
.ttr-btn-down.active:active,
.ttr-btn-down.active:focus {
background: #d9485f !important;
border-color: #d9485f !important;
color: #fff !important;
}
.ttr-link-btn,
.ttr-link-btn:visited,
.ttr-link-btn:hover,
.ttr-link-btn:active,
.ttr-link-btn:focus,
.ttr-self-btn,
.ttr-modal-save-top,
.ttr-tab-btn {
color: #fff !important;
background: rgba(255,255,255,0.04) !important;
border-color: rgba(255,255,255,0.22) !important;
text-decoration: none !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
.ttr-modal-save-top {
border-color: rgba(111,226,111,0.45) !important;
color: #6fe26f !important;
width: auto !important;
flex: 0 0 auto !important;
padding: 7px 10px !important;
}
.ttr-btn:disabled,
.ttr-self-btn:disabled {
opacity: 0.55 !important;
cursor: not-allowed !important;
}
.ttr-modal-backdrop {
position: fixed !important;
inset: 0 !important;
background: rgba(0,0,0,0.60) !important;
z-index: 2147483646 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 12px !important;
}
.ttr-modal {
position: relative !important;
width: min(560px, 100%) !important;
max-height: min(88vh, 820px) !important;
overflow: auto !important;
background: #111827 !important;
color: #f3f4f6 !important;
border: 1px solid #374151 !important;
border-radius: 10px !important;
padding: 16px !important;
box-sizing: border-box !important;
box-shadow: 0 10px 30px rgba(0,0,0,0.35) !important;
}
.ttr-modal h3 { margin: 0 0 10px 0 !important; font-size: 16px !important; }
.ttr-modal p { margin: 0 0 10px 0 !important; line-height: 1.45 !important; opacity: 0.92 !important; }
.ttr-modal input[type="text"] {
width: 100% !important;
box-sizing: border-box !important;
padding: 8px 10px !important;
border-radius: 6px !important;
border: 1px solid #4b5563 !important;
background: #0b1220 !important;
color: #f3f4f6 !important;
margin: 8px 0 12px 0 !important;
font-size: 12px !important;
}
.ttr-modal .ttr-modal-note { opacity: 0.72 !important; font-size: 12px !important; }
.ttr-modal label {
display: flex !important;
align-items: center !important;
gap: 8px !important;
margin: 8px 0 !important;
}
.ttr-modal-topbar {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 8px !important;
margin-bottom: 8px !important;
}
.ttr-modal-topbar h3 {
margin: 0 !important;
flex: 1 1 auto !important;
min-width: 0 !important;
}
.ttr-modal-tabs {
display: flex !important;
gap: 8px !important;
flex-wrap: wrap !important;
margin: 10px 0 14px 0 !important;
}
.ttr-tab-btn.active {
border-color: rgba(111,226,111,0.45) !important;
color: #6fe26f !important;
}
.ttr-tab-panel { display: none !important; }
.ttr-tab-panel.active { display: block !important; }
.ttr-mug-option {
appearance: none !important;
-webkit-appearance: none !important;
width: 100% !important;
text-align: left !important;
border: 1px solid #374151 !important;
background: #0b1220 !important;
color: #f3f4f6 !important;
border-radius: 8px !important;
padding: 10px 12px !important;
margin-top: 8px !important;
cursor: pointer !important;
font-size: 13px !important;
box-sizing: border-box !important;
}
.ttr-mug-option:hover { filter: brightness(1.06) !important; }
.ttr-mug-actions-grid {
display: grid !important;
grid-template-columns: 1fr !important;
gap: 8px !important;
margin-top: 12px !important;
}
.ttr-votes-tools {
display: grid !important;
grid-template-columns: minmax(0, 1fr) !important;
gap: 8px !important;
margin: 8px 0 10px 0 !important;
}
.ttr-votes-search {
width: 100% !important;
box-sizing: border-box !important;
padding: 8px 10px !important;
border-radius: 6px !important;
border: 1px solid #4b5563 !important;
background: #0b1220 !important;
color: #f3f4f6 !important;
margin: 0 !important;
font-size: 12px !important;
}
.ttr-votes-list {
margin-top: 10px !important;
border: 1px solid #374151 !important;
border-radius: 8px !important;
overflow: hidden !important;
}
.ttr-votes-head, .ttr-votes-row {
display: grid !important;
grid-template-columns: minmax(0, 1.6fr) 110px 110px !important;
gap: 10px !important;
align-items: center !important;
padding: 10px 12px !important;
}
.ttr-votes-head { background: rgba(255,255,255,0.05) !important; font-weight: 700 !important; }
.ttr-votes-row { border-top: 1px solid rgba(255,255,255,0.08) !important; }
.ttr-votes-empty { padding: 14px 12px !important; opacity: 0.8 !important; }
.ttr-votes-link {
color: #93c5fd !important;
text-decoration: none !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
display: block !important;
}
.ttr-vote-up { color: #6fe26f !important; font-weight: 700 !important; }
.ttr-vote-down { color: #ff7b7b !important; font-weight: 700 !important; }
.ttr-votes-row.hidden { display: none !important; }
.ttr-contact-text {
margin-bottom: 10px !important;
opacity: 0.92 !important;
line-height: 1.45 !important;
}
.ttr-perm-list {
display: grid !important;
grid-template-columns: 1fr !important;
gap: 6px !important;
margin: 8px 0 12px 0 !important;
}
.ttr-perm-item {
display: block !important;
padding: 8px 10px !important;
border-radius: 6px !important;
background: rgba(255,255,255,0.03) !important;
border: 1px solid rgba(255,255,255,0.08) !important;
font-size: 13px !important;
word-break: break-word !important;
}
.ttr-button-row {
display: grid !important;
grid-template-columns: 1fr !important;
gap: 10px !important;
width: 100% !important;
margin-top: 10px !important;
}
@media (min-width: 640px) {
.ttr-button-row.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
}
@media (min-width: 760px) {
.ttr-button-row.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
}
/* Mobile / narrow profile layout: 4 rows × 2 columns */
@media (max-width: 900px) {
.ttr-inline-card { padding: 9px 10px !important; }
.ttr-inline-main {
grid-template-columns: minmax(0,1fr) auto minmax(0,1fr) !important;
gap: 6px !important;
font-size: 11px !important;
}
.ttr-inline-stats-row {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 8px !important;
}
.ttr-stat-cell {
white-space: normal !important;
overflow: visible !important;
text-overflow: unset !important;
line-height: 1.4 !important;
}
.ttr-inline-actions { gap: 6px !important; }
.ttr-btn,
.ttr-btn:visited,
.ttr-btn:hover,
.ttr-btn:active,
.ttr-btn:focus,
.ttr-self-btn,
.ttr-link-btn,
.ttr-tab-btn,
.ttr-modal-save-top {
padding: 8px 4px !important;
font-size: 11px !important;
}
.ttr-votes-head, .ttr-votes-row {
grid-template-columns: minmax(0, 1.4fr) 80px 80px !important;
gap: 8px !important;
font-size: 12px !important;
}
}
@media (max-width: 560px) {
.ttr-modal {
padding: 14px !important;
}
.ttr-modal-topbar {
flex-direction: row !important;
align-items: center !important;
justify-content: space-between !important;
gap: 8px !important;
flex-wrap: nowrap !important;
}
.ttr-modal-topbar h3 {
flex: 1 1 auto !important;
min-width: 0 !important;
}
.ttr-modal-save-top {
width: auto !important;
flex: 0 0 auto !important;
}
.ttr-modal-tabs {
display: grid !important;
grid-template-columns: 1fr !important;
}
.ttr-tab-btn {
width: 100% !important;
}
}
@media (max-width: 430px) {
.ttr-inline-reason-row {
flex-direction: column !important;
gap: 2px !important;
}
.ttr-inline-actions {
grid-template-columns: 1fr !important;
}
}
`;
document.head.appendChild(style);
}
function loadPanelState() {
try {
const raw = getPersistentValue(STORAGE_KEY_PANEL_STATE);
if (!raw) return { collapsed: false };
const parsed = JSON.parse(raw);
return { collapsed: !!parsed?.collapsed };
} catch {
return { collapsed: false };
}
}
async function persistPanelState() {
try {
setPersistentValue(STORAGE_KEY_PANEL_STATE, JSON.stringify(panelState));
} catch {}
}
function installRouteHooks() {
const trigger = () => {
setTimeout(() => {
refreshForCurrentRoute().catch((err) => {
console.error('[TTR] Route refresh error:', err);
});
scheduleEventsSync(true);
}, 150);
};
window.addEventListener('hashchange', trigger);
window.addEventListener('popstate', trigger);
const wrapHistory = (fnName) => {
const original = history[fnName];
if (typeof original !== 'function') return;
history[fnName] = function (...args) {
const out = original.apply(this, args);
trigger();
return out;
};
};
wrapHistory('pushState');
wrapHistory('replaceState');
}
function getInjectedPdaApiKey() {
const raw = String(PDA_KEY ?? '').trim();
if (!raw) return null;
if (raw.charAt(0) === '#') return null;
return raw;
}
function getStoredTornApiKey() {
return safeStorageGet(STORAGE_KEY_TORN) || '';
}
function setStoredTornApiKey(key) {
const oldKey = getStoredTornApiKey();
const cleaned = String(key || '').trim();
if (cleaned) safeStorageSet(STORAGE_KEY_TORN, cleaned);
else safeStorageRemove(STORAGE_KEY_TORN);
if (cleaned !== oldKey) {
selfProfileCache = null;
clearAllTtrCaches();
}
}
function getEffectiveTornApiKey() {
return getStoredTornApiKey() || getInjectedPdaApiKey() || null;
}
function getStoredTeApiKey() {
return safeStorageGet(STORAGE_KEY_TE) || '';
}
function isTeEnabled() {
const raw = safeStorageGet(STORAGE_KEY_TE_ENABLED);
return raw !== '0';
}
function setStoredTeApiKey(key) {
const oldKey = getStoredTeApiKey();
const cleaned = String(key || '').trim();
if (cleaned) safeStorageSet(STORAGE_KEY_TE, cleaned);
else safeStorageRemove(STORAGE_KEY_TE);
if (cleaned !== oldKey) {
clearSupplementalCache();
}
}
function setTeEnabled(enabled) {
const old = isTeEnabled();
safeStorageSet(STORAGE_KEY_TE_ENABLED, enabled ? '1' : '0');
if (old !== enabled) {
clearSupplementalCache();
}
}
function safeStorageGet(key) {
try { return localStorage.getItem(key); } catch { return null; }
}
function safeStorageSet(key, value) {
try { localStorage.setItem(key, value); } catch {}
}
function safeStorageRemove(key) {
try { localStorage.removeItem(key); } catch {}
}
function safeSessionGet(key) {
try { return sessionStorage.getItem(key); } catch { return null; }
}
function safeSessionSet(key, value) {
try { sessionStorage.setItem(key, value); } catch {}
}
function getPersistentValue(key) {
return safeStorageGet(key) ?? safeSessionGet(key) ?? null;
}
function setPersistentValue(key, value) {
let wrote = false;
try {
localStorage.setItem(key, value);
wrote = true;
} catch {}
if (!wrote) {
try { sessionStorage.setItem(key, value); } catch {}
}
}
function safeReadJson(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw);
} catch {
try {
const raw = sessionStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw);
} catch {
return fallback;
}
}
}
function safeWriteJson(key, value) {
const raw = JSON.stringify(value);
let wrote = false;
try {
localStorage.setItem(key, raw);
wrote = true;
} catch {}
if (!wrote) {
try { sessionStorage.setItem(key, raw); } catch {}
}
}
function loadSyncedEventRefs() {
try {
const raw = getPersistentValue(STORAGE_KEY_SYNCED_EVENT_REFS);
if (!raw) return new Set();
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return new Set();
return new Set(arr.filter(Boolean));
} catch {
return new Set();
}
}
function saveSyncedEventRefs(set) {
try {
const arr = Array.from(set).slice(-MAX_SYNCED_EVENT_REFS);
setPersistentValue(STORAGE_KEY_SYNCED_EVENT_REFS, JSON.stringify(arr));
} catch {}
}
function getNowMs() {
return Date.now();
}
function makeCacheEntry(data, ttlMs = CACHE_TTL_MS) {
return {
savedAt: getNowMs(),
expiresAt: getNowMs() + ttlMs,
data
};
}
function isCacheEntryValid(entry) {
return !!entry && typeof entry === 'object' && Number(entry.expiresAt || 0) > getNowMs();
}
function readCacheBucket(bucketKey) {
const raw = safeReadJson(bucketKey, {});
return raw && typeof raw === 'object' ? raw : {};
}
function writeCacheBucket(bucketKey, bucket) {
safeWriteJson(bucketKey, bucket || {});
}
function getCacheValue(bucketKey, itemKey) {
const bucket = readCacheBucket(bucketKey);
const entry = bucket[itemKey];
if (!isCacheEntryValid(entry)) {
if (entry) {
delete bucket[itemKey];
writeCacheBucket(bucketKey, bucket);
}
return null;
}
return entry.data ?? null;
}
function setCacheValue(bucketKey, itemKey, data, ttlMs = CACHE_TTL_MS) {
const bucket = readCacheBucket(bucketKey);
bucket[itemKey] = makeCacheEntry(data, ttlMs);
writeCacheBucket(bucketKey, bucket);
}
function deleteCacheValue(bucketKey, itemKey) {
const bucket = readCacheBucket(bucketKey);
if (Object.prototype.hasOwnProperty.call(bucket, itemKey)) {
delete bucket[itemKey];
writeCacheBucket(bucketKey, bucket);
}
}
function pruneBucket(bucketKey, maxEntries = 300) {
const bucket = readCacheBucket(bucketKey);
const now = getNowMs();
let changed = false;
for (const key of Object.keys(bucket)) {
const entry = bucket[key];
if (!entry || Number(entry.expiresAt || 0) <= now) {
delete bucket[key];
changed = true;
}
}
const keys = Object.keys(bucket);
if (keys.length > maxEntries) {
keys.sort((a, b) => Number(bucket[b]?.savedAt || 0) - Number(bucket[a]?.savedAt || 0));
const keep = new Set(keys.slice(0, maxEntries));
for (const key of keys) {
if (!keep.has(key)) {
delete bucket[key];
changed = true;
}
}
}
if (changed) writeCacheBucket(bucketKey, bucket);
}
function clearSupplementalCache() {
safeStorageRemove(CACHE_KEYS.SUPPLEMENTAL);
try { sessionStorage.removeItem(CACHE_KEYS.SUPPLEMENTAL); } catch {}
}
function clearAllTtrCaches() {
for (const key of Object.values(CACHE_KEYS)) {
safeStorageRemove(key);
try { sessionStorage.removeItem(key); } catch {}
}
}
function getViewerScopedProfileCacheKey(viewerId, targetId) {
return `${viewerId}:${targetId}`;
}
function getSelfProfileCacheKey() {
return 'self';
}
function getSupplementalCacheKey(targetId) {
return String(targetId || '');
}
function getResolveUserCacheKey(name) {
return normalizeName(name || '');
}
function isProfilePage() {
return location.pathname === '/profiles.php' && !!new URL(location.href).searchParams.get('XID');
}
function isAnyTradePage() {
return location.pathname === '/trade.php';
}
function isTradeRelevantPage() {
if (!isAnyTradePage()) return false;
const step = getHashStep().toLowerCase();
return ['view', 'initiatetrade', 'add', 'confirm'].includes(step) || !!document.querySelector('.trade-cont.m-top10');
}
function isEventsPage() {
const url = new URL(location.href);
return location.pathname === '/page.php' && url.searchParams.get('sid') === 'events';
}
function getHashStep() {
const hash = String(location.hash || '');
const m1 = hash.match(/[#/]step=([^&]+)/i);
if (m1) return m1[1];
const m2 = hash.match(/step=([^&]+)/i);
if (m2) return m2[1];
return '';
}
function getTradeIdFromHash() {
const hash = String(location.hash || '');
const m = hash.match(/(?:^|[&#])ID=(\d+)/i);
return m ? String(m[1]) : null;
}
function getProfileTargetId() {
const xid = Number(new URL(location.href).searchParams.get('XID'));
return Number.isInteger(xid) && xid > 0 ? xid : null;
}
function getProfileTargetName() {
const title =
document.querySelector('h4') ||
document.querySelector('.content-title h4') ||
document.querySelector('.profile-wrapper h4');
const txt = String(title?.textContent || '').trim();
if (!txt) return '';
return txt.replace(/'s Profile$/i, '').trim();
}
function getTradeTargetNameFromDom() {
const tradeNames = extractTradeNamesFromPage();
const selfNameNorm = normalizeName(selfProfileCache?.name || '');
if (tradeNames.length) {
const other = tradeNames.find(n => normalizeName(n) !== selfNameNorm) || tradeNames[0];
return String(other || '').trim();
}
const rightTitle = document.querySelector('.trade-cont.m-top10 .user.right .title-black, .trade-cont.m-top10 .user.right.tt-modified .title-black');
return String(rightTitle?.textContent || '').trim();
}
function normalizeName(name) {
return String(name || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function gmRequest(details) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== 'function') {
reject(new Error('GM_xmlhttpRequest unavailable'));
return;
}
GM_xmlhttpRequest({
timeout: 30000,
...details,
onload: (res) => resolve(res),
onerror: (err) => reject(err || new Error('Network error')),
ontimeout: () => reject(new Error('Timeout'))
});
});
}
async function gmGetJson(url, extraHeaders = {}) {
const res = await gmRequest({
method: 'GET',
url,
headers: {
Accept: 'application/json',
...extraHeaders
}
});
let data = null;
try {
data = JSON.parse(res.responseText);
} catch {
throw new Error(`Invalid JSON response (${res.status})`);
}
return {
status: res.status,
ok: res.status >= 200 && res.status < 300,
data
};
}
async function gmPostJson(url, body, extraHeaders = {}) {
const res = await gmRequest({
method: 'POST',
url,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...extraHeaders
},
data: JSON.stringify(body)
});
let data = null;
try {
data = JSON.parse(res.responseText);
} catch {}
return {
status: res.status,
ok: res.status >= 200 && res.status < 300,
data
};
}
async function getSelfProfile() {
if (selfProfileCache?.id) return selfProfileCache;
const cached = getCacheValue(CACHE_KEYS.SELF_PROFILE, getSelfProfileCacheKey());
if (cached?.id) {
selfProfileCache = cached;
return selfProfileCache;
}
const apiKey = getEffectiveTornApiKey();
if (!apiKey) return null;
const url = new URL('https://api.torn.com/v2/user/basic');
url.searchParams.set('striptags', 'true');
url.searchParams.set('key', apiKey);
const res = await gmGetJson(url.toString());
if (!res.ok || !res.data?.profile?.id) {
throw new Error(res.data?.error?.error || `Failed to fetch self profile (${res.status})`);
}
selfProfileCache = res.data.profile;
setCacheValue(CACHE_KEYS.SELF_PROFILE, getSelfProfileCacheKey(), selfProfileCache);
pruneBucket(CACHE_KEYS.SELF_PROFILE, 3);
return selfProfileCache;
}
function isSelfTarget() {
const selfId = Number(selfProfileCache?.id || 0);
return !!selfId && !!currentTargetId && selfId === currentTargetId;
}
async function resolveUserByName(name) {
const cacheKey = getResolveUserCacheKey(name);
if (!cacheKey) return null;
const cached = getCacheValue(CACHE_KEYS.RESOLVE_USER, cacheKey);
if (cached?.id) return cached;
const res = await gmPostJson(`${WORKER_BASE}/resolve-user`, { name });
if (!res.ok || !res.data?.ok) return null;
const user = res.data.user || null;
if (user?.id) {
setCacheValue(CACHE_KEYS.RESOLVE_USER, cacheKey, user);
pruneBucket(CACHE_KEYS.RESOLVE_USER, 500);
}
return user;
}
function extractTradeNamesFromPage() {
const names = new Set();
const tradeTitle = document.querySelector('.trade-cont.m-top10 .t-title.title-black');
const titleText = String(tradeTitle?.textContent || '').trim();
const m = titleText.match(/Trade between\s+(.+?)\s*&\s*(.+)$/i);
if (m) {
if (m[1]) names.add(m[1].trim());
if (m[2]) names.add(m[2].trim());
}
const rightTitle = document.querySelector('.trade-cont.m-top10 .user.right .title-black, .trade-cont.m-top10 .user.right.tt-modified .title-black');
const rightText = String(rightTitle?.textContent || '').trim();
if (rightText) names.add(rightText);
const leftTitle = document.querySelector('.trade-cont.m-top10 .user.left .title-black, .trade-cont.m-top10 .user.left.tt-modified .title-black');
const leftText = String(leftTitle?.textContent || '').trim();
if (leftText) names.add(leftText);
return [...names].filter(Boolean);
}
function extractTradeTargetIdFromAnchors(selfId) {
const scope = document.querySelector('.trade-cont.m-top10') || document;
const anchors = Array.from(scope.querySelectorAll('a[href*="profiles.php?XID="], a[href*="/profiles.php?XID="]'));
const ids = [...new Set(
anchors
.map(a => {
const m = a.href.match(/XID=(\d+)/);
return m ? Number(m[1]) : null;
})
.filter(id => Number.isInteger(id) && id > 0)
)];
if (!ids.length) return null;
if (ids.length === 1) return ids[0];
const other = ids.find(id => id !== selfId);
return other || ids[0];
}
async function getTradeTargetId() {
const tradeContainer = document.querySelector('.trade-cont.m-top10');
if (!tradeContainer) return null;
const self = await getSelfProfile().catch(() => null);
const selfId = Number(self?.id || 0);
const selfNameNorm = normalizeName(self?.name || '');
const anchorId = extractTradeTargetIdFromAnchors(selfId);
if (anchorId && anchorId !== selfId) return anchorId;
const tradeNames = extractTradeNamesFromPage();
if (tradeNames.length) {
const otherName = tradeNames.find(name => normalizeName(name) !== selfNameNorm) || tradeNames[0];
if (otherName) {
const resolved = await resolveUserByName(otherName).catch(() => null);
if (resolved?.id) return resolved.id;
}
}
const logAnchor = Array.from(document.querySelectorAll('.log a[href*="profiles.php?XID="], .log a[href*="/profiles.php?XID="]'))
.find(a => normalizeName(a.textContent) !== selfNameNorm);
if (logAnchor) {
const m = logAnchor.href.match(/XID=(\d+)/);
if (m) return Number(m[1]);
}
return null;
}
function createInlineCard() {
const wrap = document.createElement('div');
wrap.id = 'ttr-inline-root';
wrap.className = 'ttr-inline-card ttr-tone-neutral';
if (panelState.collapsed) wrap.classList.add('collapsed');
wrap.innerHTML = `
<div class="ttr-inline-main" id="ttr-header">
<div class="ttr-first-left">
<span class="ttr-inline-label">User:</span><span id="ttr-name" class="ttr-inline-name">-</span>
</div>
<div class="ttr-first-center">
🎖️ Torn Trade Reputation 🎖️
</div>
<div class="ttr-first-right">
<div class="ttr-inline-top-right">
<button class="ttr-gear" id="ttr-collapse-btn" title="Collapse / Expand">${panelState.collapsed ? '▲' : '▼'}</button>
<button id="ttr-settings" class="ttr-gear" type="button" title="Setting">⚙</button>
</div>
</div>
</div>
<div id="ttr-collapsed-summary" class="ttr-collapsed-summary"></div>
<div id="ttr-body">
<div class="ttr-inline-reason-row" id="ttr-mug-row">
<span><span class="ttr-inline-label">Mug Reports:</span><span id="ttr-mugreports" class="ttr-inline-vote neutral">0</span></span>
<span><span class="ttr-inline-label">Reason:</span><span id="ttr-mug-top-reason" class="ttr-inline-vote neutral">-</span></span>
</div>
<div class="ttr-inline-stats-row">
<div class="ttr-stat-cell">
<span class="ttr-risk-label">Risk Label:</span>
<span id="ttr-risk-label" class="ttr-risk-value neutral">Neutral</span>
</div>
<div class="ttr-stat-cell">
<span class="ttr-inline-label">Current Bounty:</span>
<span id="ttr-current-bounty" class="ttr-inline-meta-value neg">$0</span>
</div>
<div class="ttr-stat-cell">
<span class="ttr-inline-label">90d Mugged:</span>
<span id="ttr-mug90d" class="ttr-inline-meta-value neutral">-</span>
</div>
<div class="ttr-stat-cell">
<span class="ttr-inline-label">Friend - Enemy:</span>
<span id="ttr-fe" class="ttr-inline-meta-value neutral">-</span>
</div>
</div>
<div class="ttr-inline-stats-row">
<div class="ttr-stat-cell">
<span class="ttr-inline-label">Your Vote:</span>
<span id="ttr-myvote" class="ttr-inline-vote neutral">0</span>
</div>
<div class="ttr-stat-cell">
<span class="ttr-inline-label">Score:</span>
<span id="ttr-score" class="ttr-inline-score neutral">-</span>
</div>
<div class="ttr-stat-cell">
<span class="ttr-inline-label">Trades Seen:</span>
<span id="ttr-trade-history" class="ttr-inline-history-value neutral">0</span>
</div>
<div class="ttr-stat-cell">
<span class="ttr-inline-label">Torn Exchange:</span>
<span id="ttr-te" class="ttr-inline-meta-value neutral">-</span>
</div>
</div>
<div><span id="ttr-status" class="ttr-inline-status"></span></div>
<div id="ttr-self-row" class="ttr-inline-self-row">
<button id="ttr-self-votes-btn" class="ttr-self-btn" type="button">People I Voted</button>
</div>
<div id="ttr-actions" class="ttr-inline-actions">
<button id="ttr-up" class="ttr-btn ttr-btn-up" type="button">Upvote</button>
<button id="ttr-down" class="ttr-btn ttr-btn-down" type="button">Downvote</button>
</div>
</div>
`;
rootEl = wrap;
bodyEl = wrap.querySelector('#ttr-body');
collapsedSummaryEl = wrap.querySelector('#ttr-collapsed-summary');
mugRowEl = wrap.querySelector('#ttr-mug-row');
nameEl = wrap.querySelector('#ttr-name');
scoreEl = wrap.querySelector('#ttr-score');
myVoteEl = wrap.querySelector('#ttr-myvote');
mugReportsEl = wrap.querySelector('#ttr-mugreports');
mugTopReasonEl = wrap.querySelector('#ttr-mug-top-reason');
tradeHistoryEl = wrap.querySelector('#ttr-trade-history');
statusEl = wrap.querySelector('#ttr-status');
btnUp = wrap.querySelector('#ttr-up');
btnDown = wrap.querySelector('#ttr-down');
actionsEl = wrap.querySelector('#ttr-actions');
friendEnemyEl = wrap.querySelector('#ttr-fe');
teEl = wrap.querySelector('#ttr-te');
settingsBtn = wrap.querySelector('#ttr-settings');
selfVotesBtn = wrap.querySelector('#ttr-self-votes-btn');
riskLabelEl = wrap.querySelector('#ttr-risk-label');
currentBountyEl = wrap.querySelector('#ttr-current-bounty');
mug90dEl = wrap.querySelector('#ttr-mug90d');
collapseBtn = wrap.querySelector('#ttr-collapse-btn');
btnUp.addEventListener('click', () => submitVote(1));
btnDown.addEventListener('click', () => handleDownvoteFlow());
settingsBtn.addEventListener('click', openSettingsModal);
selfVotesBtn.addEventListener('click', openMyVotesModal);
collapseBtn.addEventListener('click', async (e) => {
e.stopPropagation();
panelState.collapsed = !panelState.collapsed;
wrap.classList.toggle('collapsed', panelState.collapsed);
collapseBtn.textContent = panelState.collapsed ? '▲' : '▼';
await persistPanelState();
updateCollapsedSummary();
});
updateMugRowVisibility(0);
updateCollapsedSummary();
return wrap;
}
function getProfileMountPoint() {
const ffInfo = document.getElementById('ff-scouter-run-once');
if (ffInfo && ffInfo.parentNode) {
return { parent: ffInfo.parentNode, before: ffInfo.nextSibling || null };
}
const contentTitle =
document.querySelector('.content-title') ||
document.querySelector('h4')?.parentNode ||
document.querySelector('.profile-wrapper h4')?.parentNode;
if (contentTitle && contentTitle.parentNode) {
return { parent: contentTitle.parentNode, before: contentTitle.nextSibling };
}
const h4 = document.querySelector('h4');
if (h4?.parentNode?.parentNode) {
return { parent: h4.parentNode.parentNode, before: h4.parentNode.nextSibling };
}
return null;
}
function getTradeMountPoint() {
const invoice = document.querySelector('.reza-invoice-wrap');
if (invoice && invoice.parentNode) {
return { parent: invoice.parentNode, before: invoice };
}
const tradeContainer = document.querySelector('.trade-cont.m-top10');
if (tradeContainer && tradeContainer.parentNode) {
return { parent: tradeContainer.parentNode, before: tradeContainer };
}
const warnBox = document.querySelector('.info-msg-cont.border-round.m-top10');
if (warnBox && warnBox.parentNode) {
return { parent: warnBox.parentNode, before: warnBox.nextSibling };
}
const title = document.querySelector('.trade-wrap, .trade-cont');
if (title && title.parentNode) {
return { parent: title.parentNode, before: title };
}
return null;
}
function ensureMounted(contextType) {
const point = contextType === 'trade' ? getTradeMountPoint() : getProfileMountPoint();
if (!point) return false;
let existing = document.getElementById('ttr-mount-row');
if (!existing) {
existing = document.createElement('div');
existing.id = 'ttr-mount-row';
existing.className = 'ttr-mount-row';
}
const desiredBefore = point.before || null;
if (existing.parentNode !== point.parent || existing.nextSibling !== desiredBefore) {
point.parent.insertBefore(existing, desiredBefore);
}
let card = document.getElementById('ttr-inline-root');
if (!card) {
card = createInlineCard();
existing.appendChild(card);
} else if (card.parentNode !== existing) {
existing.appendChild(card);
}
rootEl = card;
return true;
}
function setStatus(text, centered = false) {
if (!statusEl) return;
statusEl.textContent = text || '';
statusEl.classList.toggle('centered', !!centered);
updateCollapsedSummary();
}
function setDisabled(disabled) {
if (btnUp) btnUp.disabled = !!disabled;
if (btnDown) btnDown.disabled = !!disabled;
if (selfVotesBtn) selfVotesBtn.disabled = !!disabled;
}
function setActionsVisible(visible) {
if (!actionsEl) return;
actionsEl.classList.toggle('hidden', !visible);
}
function setSelfVotesButtonVisible(visible) {
const row = document.getElementById('ttr-self-row');
if (!row) return;
row.classList.toggle('show', !!visible);
}
function setScore(value) {
if (!scoreEl) return;
scoreEl.textContent = String(value ?? 0);
scoreEl.classList.remove('pos', 'neg', 'neutral');
const n = Number(value || 0);
if (n > 0) scoreEl.classList.add('pos');
else if (n < 0) scoreEl.classList.add('neg');
else scoreEl.classList.add('neutral');
updateCollapsedSummary();
}
function setVote(vote) {
currentViewerVote = vote == null ? null : Number(vote);
if (!myVoteEl) return;
myVoteEl.classList.remove('pos', 'neg', 'neutral');
btnUp?.classList.remove('active');
btnDown?.classList.remove('active');
if (vote === 1) {
myVoteEl.textContent = '+1';
myVoteEl.classList.add('pos');
btnUp?.classList.add('active');
} else if (vote === -1) {
myVoteEl.textContent = '-1';
myVoteEl.classList.add('neg');
btnDown?.classList.add('active');
} else {
myVoteEl.textContent = '0';
myVoteEl.classList.add('neutral');
}
updateCollapsedSummary();
}
function updateMugRowVisibility(value) {
if (!mugRowEl) return;
const shown = Number(value || 0);
mugRowEl.style.display = shown > 0 ? 'flex' : 'none';
}
function setMugReports(value) {
if (!mugReportsEl) return;
const n = toNullableInt(value);
const shown = n === null ? 0 : n;
mugReportsEl.textContent = String(shown);
mugReportsEl.classList.remove('pos', 'neg', 'neutral');
if (shown > 0) mugReportsEl.classList.add('neg');
else mugReportsEl.classList.add('neutral');
updateMugRowVisibility(shown);
}
function setMugTopReason(topReason) {
if (!mugTopReasonEl) return;
mugTopReasonEl.classList.remove('neg', 'neutral');
const label = topReason?.label || '-';
mugTopReasonEl.textContent = label;
if (label && label !== '-') mugTopReasonEl.classList.add('neg');
else mugTopReasonEl.classList.add('neutral');
}
function setFriendEnemy(friendEnemy) {
if (!friendEnemyEl) return;
friendEnemyEl.classList.remove('pos', 'neg', 'neutral');
const diff = toNullableInt(friendEnemy?.diff);
if (diff === null) {
friendEnemyEl.textContent = '-';
friendEnemyEl.classList.add('neutral');
updateCollapsedSummary();
return;
}
friendEnemyEl.textContent = diff > 0 ? `+${diff}` : String(diff);
if (diff > 0) friendEnemyEl.classList.add('pos');
else if (diff < 0) friendEnemyEl.classList.add('neg');
else friendEnemyEl.classList.add('neutral');
updateCollapsedSummary();
}
function setTe(te) {
if (!teEl) return;
teEl.classList.remove('pos', 'neg', 'neutral');
const votes = toNullableInt(te?.votes);
if (votes === null) {
teEl.textContent = '-';
teEl.classList.add('neutral');
updateCollapsedSummary();
return;
}
teEl.textContent = votes > 0 ? `+${votes}` : String(votes);
if (votes > 0) teEl.classList.add('pos');
else if (votes < 0) teEl.classList.add('neg');
else teEl.classList.add('neutral');
updateCollapsedSummary();
}
function setTradeHistorySeen(value) {
if (!tradeHistoryEl) return;
const count = toNullableInt(value);
const shown = count === null ? 0 : count;
tradeHistoryEl.textContent = String(shown);
tradeHistoryEl.classList.remove('pos', 'neg', 'neutral');
if (shown > 0) tradeHistoryEl.classList.add('pos');
else tradeHistoryEl.classList.add('neutral');
updateCollapsedSummary();
}
function formatMoneyCompact(value) {
const n = Number(value || 0);
if (!Number.isFinite(n)) return '-';
if (n <= 0) return '$0';
if (n >= 1000000000) return `$${(n / 1000000000).toFixed(2)}B`;
if (n >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (n >= 1000) return `$${(n / 1000).toFixed(2)}K`;
return `$${Math.round(n)}`;
}
function setCurrentBountyTotal(value) {
if (!currentBountyEl) return;
const n = Number(value || 0);
currentBountyEl.classList.remove('pos', 'neg', 'neutral');
currentBountyEl.textContent = formatMoneyCompact(n);
currentBountyEl.classList.add('neg');
updateCollapsedSummary();
}
function setMuggedLast90d(value) {
if (!mug90dEl) return;
const n = Number(value || 0);
mug90dEl.classList.remove('pos', 'neg', 'neutral');
if (!Number.isFinite(n) || n < 0) {
mug90dEl.textContent = '-';
mug90dEl.classList.add('neutral');
updateCollapsedSummary();
return;
}
mug90dEl.textContent = formatMoneyCompact(n);
if (n >= 250000000) mug90dEl.classList.add('neg');
else mug90dEl.classList.add('neutral');
updateCollapsedSummary();
}
function applyRiskTone(tone) {
if (!rootEl) return;
rootEl.classList.remove('ttr-tone-safe', 'ttr-tone-neutral', 'ttr-tone-risky');
if (tone === 'safe') rootEl.classList.add('ttr-tone-safe');
else if (tone === 'risky') rootEl.classList.add('ttr-tone-risky');
else rootEl.classList.add('ttr-tone-neutral');
}
function getRiskAssessment(data) {
const viewerVote = Number(data?.viewerVote || 0);
const score = Number(data?.score || 0);
const mugReports = Number(data?.mugReports || 0);
const friends = Number(data?.friendEnemy?.friends || 0);
const enemies = Number(data?.friendEnemy?.enemies || 0);
const diff = Number(
data?.friendEnemy?.diff ??
((Number.isFinite(friends) && Number.isFinite(enemies)) ? (friends - enemies) : 0)
);
const currentBounty = Number(data?.currentBountyTotal || 0);
const mugged90d = Number(data?.muggedLast90d || 0);
const enemiesDominate = enemies > friends;
if (viewerVote === 1) {
if (!enemiesDominate) return { key: 'safe', label: 'Safe', tone: 'safe' };
if (mugReports > 0 || currentBounty > 0 || mugged90d >= 1000000000) {
return { key: 'neutral', label: 'Neutral', tone: 'neutral' };
}
return { key: 'safe', label: 'Safe', tone: 'safe' };
}
if (viewerVote === -1) {
return { key: 'risky', label: 'Likely Buy-Mugger', tone: 'risky' };
}
if (score >= 3) {
if (!enemiesDominate) return { key: 'safe', label: 'Safe', tone: 'safe' };
return { key: 'neutral', label: 'Neutral', tone: 'neutral' };
}
if (score <= -3) {
return { key: 'risky', label: 'Likely Buy-Mugger', tone: 'risky' };
}
if (!enemiesDominate) {
if (diff > 0) return { key: 'safe', label: 'Safe', tone: 'safe' };
return { key: 'neutral', label: 'Neutral', tone: 'neutral' };
}
let danger = 0;
if (mugReports > 0) danger += 2;
if (currentBounty > 0) danger += 1;
if (mugged90d >= 1000000000) danger += 2;
else if (mugged90d >= 250000000) danger += 1;
if (diff <= -500) danger += 2;
else if (diff < 0) danger += 1;
if (danger >= 4) {
return { key: 'risky', label: 'Likely Buy-Mugger', tone: 'risky' };
}
return { key: 'neutral', label: 'Neutral', tone: 'neutral' };
}
function setRiskLabel(data) {
if (!riskLabelEl) return;
const risk = getRiskAssessment(data);
riskLabelEl.textContent = risk.label;
riskLabelEl.classList.remove('pos', 'neg', 'neutral');
if (risk.tone === 'safe') riskLabelEl.classList.add('pos');
else if (risk.tone === 'risky') riskLabelEl.classList.add('neg');
else riskLabelEl.classList.add('neutral');
applyRiskTone(risk.tone);
updateCollapsedSummary();
}
function getValueClass(el) {
if (!el) return 'neutral';
if (el.classList.contains('pos')) return 'pos';
if (el.classList.contains('neg')) return 'neg';
return 'neutral';
}
function makeCollapsedPiece(label, el, fallback) {
const value = String(el?.textContent || fallback || '-').trim() || '-';
const cls = getValueClass(el);
return `<span class="ttr-collapsed-piece">${escapeHtml(label)}: <span class="ttr-collapsed-value ${cls}">${escapeHtml(value)}</span></span>`;
}
function updateCollapsedSummary() {
if (!collapsedSummaryEl) return;
collapsedSummaryEl.innerHTML = [
makeCollapsedPiece('Risk Label', riskLabelEl, 'Neutral'),
'<span class="ttr-collapsed-sep"> | </span>',
makeCollapsedPiece('Current Bounty', currentBountyEl, '$0'),
'<span class="ttr-collapsed-sep"> | </span>',
makeCollapsedPiece('90d Mugged', mug90dEl, '$0'),
'<span class="ttr-collapsed-sep"> | </span>',
makeCollapsedPiece('Friend - Enemy', friendEnemyEl, '-'),
'<span class="ttr-collapsed-sep"> | </span>',
makeCollapsedPiece('Your Vote', myVoteEl, '0'),
'<span class="ttr-collapsed-sep"> | </span>',
makeCollapsedPiece('Score', scoreEl, '0'),
'<span class="ttr-collapsed-sep"> | </span>',
makeCollapsedPiece('Trades Seen', tradeHistoryEl, '0'),
'<span class="ttr-collapsed-sep"> | </span>',
makeCollapsedPiece('Torn Exchange', teEl, '-')
].join('');
}
function openTornKeyPrompt() {
if (getEffectiveTornApiKey()) return;
const existing = document.getElementById('ttr-torn-key-backdrop');
if (existing) return;
const backdrop = document.createElement('div');
backdrop.id = 'ttr-torn-key-backdrop';
backdrop.className = 'ttr-modal-backdrop';
backdrop.innerHTML = `
<div class="ttr-modal">
<h3>Torn API Key Required</h3>
<p>To use Torn Trade Reputation outside Torn PDA auto-injection, please save your Torn <strong>public</strong> API key here.</p>
<p class="ttr-modal-note">This key is stored locally in your browser only and is used so the script can identify you and load profile / trade reputation correctly.</p>
<input id="ttr-torn-key-input" type="text" placeholder="Paste your Torn public API key here" value="" />
<div class="ttr-button-row">
<button id="ttr-torn-key-save" class="ttr-modal-save-top" type="button">Save and close</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) backdrop.remove();
});
backdrop.querySelector('#ttr-torn-key-save')?.addEventListener('click', async () => {
const input = backdrop.querySelector('#ttr-torn-key-input');
const value = String(input?.value || '').trim();
if (!value) return;
setStoredTornApiKey(value);
backdrop.remove();
if (currentTargetId) {
try {
await refreshProfileCard({ preferCache: false });
await refreshSupplementalData(currentTargetId, { preferCache: false });
} catch (err) {
console.warn('[TTR] Refresh after Torn key save warning:', err);
}
}
scheduleEventsSync(true);
});
}
function activateSettingsTab(backdrop, tabName) {
backdrop.querySelectorAll('.ttr-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
backdrop.querySelectorAll('.ttr-tab-panel').forEach(panel => {
panel.classList.toggle('active', panel.dataset.tab === tabName);
});
}
function openSettingsModal() {
const existing = document.getElementById('ttr-modal-backdrop');
if (existing) existing.remove();
const backdrop = document.createElement('div');
backdrop.id = 'ttr-modal-backdrop';
backdrop.className = 'ttr-modal-backdrop';
const teKey = getStoredTeApiKey();
const tornKey = getStoredTornApiKey();
backdrop.innerHTML = `
<div class="ttr-modal">
<div class="ttr-modal-topbar">
<h3>Setting</h3>
<button id="ttr-modal-save-top" class="ttr-modal-save-top" type="button">Save and close</button>
</div>
<div class="ttr-modal-tabs">
<button class="ttr-tab-btn active" data-tab="te" type="button">Torn Exchange key</button>
<button class="ttr-tab-btn" data-tab="api" type="button">API key</button>
<button class="ttr-tab-btn" data-tab="contact" type="button">Contact</button>
</div>
<div class="ttr-tab-panel active" data-tab="te">
<p>Enter your Torn Exchange API key here if you want the script to fetch TE trader reputation for the current target.</p>
<p class="ttr-modal-note">Your TE key is stored locally in your browser / PDA only. The script sends TE result data to the reputation server as a backup snapshot, but it does not send your TE key itself.</p>
<label>
<input id="ttr-te-enabled" type="checkbox" ${isTeEnabled() ? 'checked' : ''} />
Enable TE lookup
</label>
<input id="ttr-te-key" type="text" placeholder="Paste your TE API key here" value="${escapeHtmlAttr(teKey)}" />
<div class="ttr-modal-note">Use the same TE API key you use to sign in to Torn Exchange.</div>
</div>
<div class="ttr-tab-panel" data-tab="api">
<p>If you do not want to use your main Torn public API key, you can create a custom public key for Torn Trade Reputation with only the permissions this script uses:</p>
<div class="ttr-perm-list">
<div class="ttr-perm-item"><code>[/user/basic]</code></div>
<div class="ttr-perm-item"><code>[/user/profile]</code></div>
<div class="ttr-perm-item"><code>[/user/faction]</code></div>
<div class="ttr-perm-item"><code>[/user/bounties]</code></div>
<div class="ttr-perm-item"><code>[/user/personalstats]</code></div>
</div>
<input id="ttr-torn-key" type="text" placeholder="Paste your Torn public / custom API key here" value="${escapeHtmlAttr(tornKey)}" />
<div class="ttr-button-row cols-2">
<a class="ttr-link-btn" href="https://www.torn.com/preferences.php#tab=api?&step=addNewKey&title=Torn%20Trade%20Reputation&type=1" target="_blank" rel="noopener noreferrer">Create public key</a>
<a class="ttr-link-btn" href="https://www.torn.com/preferences.php#tab=api?step=addNewKey&title=Torn%20Trade%20Reputation&user=basic,profile,faction,bounties,personalstats" target="_blank" rel="noopener noreferrer">Create custom key</a>
</div>
</div>
<div class="ttr-tab-panel" data-tab="contact">
<div class="ttr-contact-text">
Report bugs, send ideas, or contact Vreebn [4149405] here.
</div>
<div class="ttr-button-row cols-3">
<a class="ttr-link-btn" href="https://www.torn.com/profiles.php?XID=4149405" target="_blank" rel="noopener noreferrer">Profile</a>
<a class="ttr-link-btn" href="https://www.torn.com/messages.php#/p=compose&XID=4149405" target="_blank" rel="noopener noreferrer">Message</a>
<a class="ttr-link-btn" href="https://www.torn.com/forums.php#/p=threads&f=67&t=16555319&b=0&a=0" target="_blank" rel="noopener noreferrer">Forum</a>
</div>
<div class="ttr-modal-note" style="margin-top:10px;">Using subject: <code>TTR:</code> helps keep messages organized.</div>
</div>
</div>
`;
document.body.appendChild(backdrop);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) backdrop.remove();
});
backdrop.querySelectorAll('.ttr-tab-btn').forEach(btn => {
btn.addEventListener('click', () => activateSettingsTab(backdrop, btn.dataset.tab));
});
backdrop.querySelector('#ttr-modal-save-top')?.addEventListener('click', async () => {
const teInput = backdrop.querySelector('#ttr-te-key');
const teEnabled = !!backdrop.querySelector('#ttr-te-enabled')?.checked;
const teValue = String(teInput?.value || '').trim();
const tornInput = backdrop.querySelector('#ttr-torn-key');
const tornValue = String(tornInput?.value || '').trim();
setStoredTeApiKey(teValue);
setTeEnabled(teEnabled);
setStoredTornApiKey(tornValue);
backdrop.remove();
setStatus('Settings saved');
if (currentTargetId) {
try {
await refreshProfileCard({ preferCache: false });
await refreshSupplementalData(currentTargetId, { preferCache: false });
} catch (err) {
console.warn('[TTR] Refresh after settings save warning:', err);
}
}
scheduleEventsSync(true);
});
}
function closeAnyModalById(id) {
const existing = document.getElementById(id);
if (existing) existing.remove();
}
function closeAnyMugModal() {
closeAnyModalById('ttr-mug-backdrop');
}
function openMugDecisionModal() {
closeAnyMugModal();
const backdrop = document.createElement('div');
backdrop.id = 'ttr-mug-backdrop';
backdrop.className = 'ttr-modal-backdrop';
backdrop.innerHTML = `
<div class="ttr-modal">
<h3>Report mug?</h3>
<p>Do you want to add a mug report for this player too?</p>
<div class="ttr-mug-actions-grid">
<button id="ttr-mug-yes" class="ttr-btn ttr-btn-down" type="button">Yes</button>
<button id="ttr-mug-no" class="ttr-link-btn" type="button">No</button>
<button id="ttr-mug-cancel" class="ttr-link-btn" type="button">Cancel</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) backdrop.remove();
});
backdrop.querySelector('#ttr-mug-cancel')?.addEventListener('click', () => {
backdrop.remove();
});
backdrop.querySelector('#ttr-mug-no')?.addEventListener('click', async () => {
backdrop.remove();
await submitVote(-1);
});
backdrop.querySelector('#ttr-mug-yes')?.addEventListener('click', () => {
backdrop.remove();
openMugSubtypeModal();
});
}
function openMugSubtypeModal() {
closeAnyMugModal();
const backdrop = document.createElement('div');
backdrop.id = 'ttr-mug-backdrop';
backdrop.className = 'ttr-modal-backdrop';
backdrop.innerHTML = `
<div class="ttr-modal">
<h3>Mug report reason</h3>
<p>Pick the subtype you want to save for this mug report.</p>
<button class="ttr-mug-option" data-subtype="after_market_purchase" type="button">${MUG_SUBTYPES.after_market_purchase}</button>
<button class="ttr-mug-option" data-subtype="after_cancelled_trade" type="button">${MUG_SUBTYPES.after_cancelled_trade}</button>
<button class="ttr-mug-option" data-subtype="other_trade_related_mug" type="button">${MUG_SUBTYPES.other_trade_related_mug}</button>
<div class="ttr-mug-actions-grid">
<button id="ttr-mug-back" class="ttr-link-btn" type="button">Back</button>
<button id="ttr-mug-cancel" class="ttr-link-btn" type="button">Cancel</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) backdrop.remove();
});
backdrop.querySelector('#ttr-mug-cancel')?.addEventListener('click', () => {
backdrop.remove();
});
backdrop.querySelector('#ttr-mug-back')?.addEventListener('click', () => {
backdrop.remove();
openMugDecisionModal();
});
backdrop.querySelectorAll('.ttr-mug-option').forEach((btn) => {
btn.addEventListener('click', async () => {
const subtype = String(btn.getAttribute('data-subtype') || '').trim();
backdrop.remove();
await submitVote(-1, { mugReportSubtype: subtype });
});
});
}
async function openMyVotesModal() {
const existing = document.getElementById('ttr-myvotes-backdrop');
if (existing) existing.remove();
const backdrop = document.createElement('div');
backdrop.id = 'ttr-myvotes-backdrop';
backdrop.className = 'ttr-modal-backdrop';
backdrop.innerHTML = `
<div class="ttr-modal">
<div class="ttr-modal-topbar">
<button id="ttr-myvotes-close-top" class="ttr-link-btn" type="button" style="width:auto !important;">Close</button>
<h3>People I Voted</h3>
<div></div>
</div>
<div class="ttr-votes-tools">
<input id="ttr-myvotes-search" class="ttr-votes-search" type="text" placeholder="Search by name or ID..." />
</div>
<div id="ttr-myvotes-content"></div>
</div>
`;
document.body.appendChild(backdrop);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) backdrop.remove();
});
backdrop.querySelector('#ttr-myvotes-close-top')?.addEventListener('click', () => {
backdrop.remove();
});
const content = backdrop.querySelector('#ttr-myvotes-content');
const searchInput = backdrop.querySelector('#ttr-myvotes-search');
try {
const apiKey = getEffectiveTornApiKey();
if (!apiKey) throw new Error('Missing Torn API key');
const res = await gmPostJson(`${WORKER_BASE}/private/my-votes`, {
apiKey,
scriptVersion: SCRIPT_VERSION
});
if (!res.ok || !res.data?.ok) {
throw new Error(res.data?.error || `Load failed (${res.status})`);
}
const votes = Array.isArray(res.data?.votes) ? res.data.votes : [];
if (!votes.length) {
content.innerHTML = `<div class="ttr-votes-empty">You have not voted anyone yet.</div>`;
return;
}
content.innerHTML = `
<div class="ttr-votes-list">
<div class="ttr-votes-head">
<div>Player</div>
<div>Vote</div>
<div>Score</div>
</div>
${votes.map((row) => {
const voteTxt = Number(row.vote) === 1 ? 'Upvote' : 'Downvote';
const voteClass = Number(row.vote) === 1 ? 'ttr-vote-up' : 'ttr-vote-down';
const targetId = Number(row.targetId || 0);
const targetName = escapeHtml(row.targetName || `User ${targetId}`);
const score = Number(row.targetScore || 0);
const scoreText = score > 0 ? `+${score}` : String(score);
const searchBlob = `${String(targetName).toLowerCase()} ${String(targetId)}`;
return `
<div class="ttr-votes-row" data-search="${escapeHtmlAttr(searchBlob)}">
<div>
<a class="ttr-votes-link" href="https://www.torn.com/profiles.php?XID=${targetId}" target="_blank" rel="noopener noreferrer">${targetName} [${targetId}]</a>
</div>
<div class="${voteClass}">${voteTxt}</div>
<div>${escapeHtml(scoreText)}</div>
</div>
`;
}).join('')}
</div>
`;
searchInput.addEventListener('input', () => {
const q = String(searchInput.value || '').trim().toLowerCase();
const rows = Array.from(content.querySelectorAll('.ttr-votes-row'));
let shown = 0;
rows.forEach((row) => {
const hay = String(row.getAttribute('data-search') || '').toLowerCase();
const match = !q || hay.includes(q);
row.classList.toggle('hidden', !match);
if (match) shown++;
});
let empty = content.querySelector('.ttr-votes-empty');
if (!shown) {
if (!empty) {
empty = document.createElement('div');
empty.className = 'ttr-votes-empty';
empty.textContent = 'No matching results.';
content.appendChild(empty);
} else {
empty.textContent = 'No matching results.';
}
} else if (empty) {
empty.remove();
}
});
} catch (err) {
content.innerHTML = `<div class="ttr-votes-empty">Failed to load: ${escapeHtml(err?.message || 'Unknown error')}</div>`;
}
}
async function handleDownvoteFlow() {
if (!currentTargetId) return;
await getSelfProfile().catch(() => null);
if (isSelfTarget()) {
setActionsVisible(false);
setSelfVotesButtonVisible(true);
setDisabled(false);
return;
}
const shownVote = currentViewerVote == null ? 0 : Number(currentViewerVote);
if (shownVote === -1) {
await submitVote(-1);
return;
}
openMugDecisionModal();
}
async function fetchUserProfileById(userId) {
const apiKey = getEffectiveTornApiKey();
if (!apiKey || !userId) return null;
const url = new URL(`https://api.torn.com/v2/user/${userId}/profile`);
url.searchParams.set('striptags', 'true');
url.searchParams.set('key', apiKey);
const res = await gmGetJson(url.toString());
if (!res.ok || !res.data?.profile?.id) {
throw new Error(res.data?.error?.error || `Failed to fetch target profile (${res.status})`);
}
return res.data.profile;
}
async function fetchCurrentBountyTotalById(userId) {
const apiKey = getEffectiveTornApiKey();
if (!apiKey || !userId) return null;
const url = new URL(`https://api.torn.com/v2/user/${userId}/bounties`);
url.searchParams.set('key', apiKey);
const res = await gmGetJson(url.toString());
if (!res.ok) {
throw new Error(res.data?.error?.error || `Failed to fetch bounties (${res.status})`);
}
const arr = Array.isArray(res.data?.bounties) ? res.data.bounties : [];
return arr.reduce((sum, row) => {
const reward = Number(row?.reward || 0);
const qty = Number(row?.quantity || 0);
return sum + (reward * qty);
}, 0);
}
async function fetchMoneyMuggedCurrentById(userId) {
const apiKey = getEffectiveTornApiKey();
if (!apiKey || !userId) return null;
const url = new URL(`https://api.torn.com/v2/user/${userId}/personalstats`);
url.searchParams.set('cat', 'all');
url.searchParams.set('key', apiKey);
const res = await gmGetJson(url.toString());
if (!res.ok) {
throw new Error(res.data?.error?.error || `Failed to fetch current personalstats (${res.status})`);
}
return Number(
res.data?.personalstats?.attacking?.networth?.money_mugged ??
res.data?.personalstats?.moneymugged ??
0
);
}
async function fetchMoneyMuggedHistoricalById(userId) {
const apiKey = getEffectiveTornApiKey();
if (!apiKey || !userId) return null;
const ts = Math.floor(Date.now() / 1000) - NINETY_DAYS_SEC;
const url = new URL(`https://api.torn.com/v2/user/${userId}/personalstats`);
url.searchParams.set('stat', 'moneymugged');
url.searchParams.set('timestamp', String(ts));
url.searchParams.set('key', apiKey);
const res = await gmGetJson(url.toString());
if (!res.ok) {
throw new Error(res.data?.error?.error || `Failed to fetch historical personalstats (${res.status})`);
}
const ps = res.data?.personalstats;
if (Array.isArray(ps)) {
const found = ps.find(x => String(x?.name || '').toLowerCase() === 'moneymugged');
return Number(found?.value || 0);
}
return Number(
ps?.attacking?.networth?.money_mugged ??
ps?.moneymugged ??
0
);
}
async function fetchMuggedLast90dById(userId) {
const [currentVal, historicalVal] = await Promise.all([
fetchMoneyMuggedCurrentById(userId),
fetchMoneyMuggedHistoricalById(userId)
]);
if (currentVal == null || historicalVal == null) return null;
return Math.max(0, Number(currentVal) - Number(historicalVal));
}
async function fetchTeProfile(targetId) {
const teKey = getStoredTeApiKey();
if (!teKey || !isTeEnabled() || !targetId) return null;
const url = new URL('https://tornexchange.com/api/profile');
url.searchParams.set('key', teKey);
url.searchParams.set('user_id', String(targetId));
const res = await gmGetJson(url.toString());
if (!res.ok || res.data?.status !== 'success' || !res.data?.data) {
throw new Error(res.data?.message || `Failed to fetch TE profile (${res.status})`);
}
const d = res.data.data;
return {
votes: toNullableInt(d.votes),
name: d.name || null,
updatedAt: d.updated_at || null
};
}
async function pushSnapshotUpdate(targetId, targetName, friendEnemy, te, currentBountyTotal, muggedLast90d) {
const apiKey = getEffectiveTornApiKey();
if (!apiKey || !targetId) return;
const payload = {
apiKey,
targetId,
targetName: targetName || '',
scriptVersion: SCRIPT_VERSION
};
if (friendEnemy) payload.friendEnemy = friendEnemy;
if (te) payload.te = te;
if (typeof currentBountyTotal === 'number') payload.currentBountyTotal = currentBountyTotal;
if (typeof muggedLast90d === 'number') payload.muggedLast90d = muggedLast90d;
const res = await gmPostJson(`${WORKER_BASE}/snapshot/update`, payload);
if (!res.ok || !res.data?.ok) {
throw new Error(res.data?.error || `Snapshot update failed (${res.status})`);
}
}
async function refreshSupplementalData(targetId, options = {}) {
if (!targetId) return;
const preferCache = options.preferCache !== false;
const cacheKey = getSupplementalCacheKey(targetId);
if (preferCache) {
const cached = getCacheValue(CACHE_KEYS.SUPPLEMENTAL, cacheKey);
if (cached) {
if (cached.friendEnemy) setFriendEnemy(cached.friendEnemy);
if (cached.te) setTe(cached.te);
if (typeof cached.currentBountyTotal === 'number') setCurrentBountyTotal(cached.currentBountyTotal);
if (typeof cached.muggedLast90d === 'number') setMuggedLast90d(cached.muggedLast90d);
return cached;
}
}
let liveFriendEnemy = null;
let liveTe = null;
let liveCurrentBountyTotal = null;
let liveMuggedLast90d = null;
try {
const targetProfile = await fetchUserProfileById(targetId);
if (targetProfile) {
const friends = toNullableInt(targetProfile.friends);
const enemies = toNullableInt(targetProfile.enemies);
const diff = (friends !== null && enemies !== null) ? friends - enemies : null;
liveFriendEnemy = {
friends,
enemies,
diff,
fetchedAt: Math.floor(Date.now() / 1000)
};
setFriendEnemy(liveFriendEnemy);
}
} catch (err) {
console.warn('[TTR] Friend/enemy fetch warning:', err);
}
try {
const te = await fetchTeProfile(targetId);
if (te) {
liveTe = te;
setTe(liveTe);
}
} catch (err) {
console.warn('[TTR] TE fetch warning:', err);
}
try {
const bountyTotal = await fetchCurrentBountyTotalById(targetId);
if (typeof bountyTotal === 'number') {
liveCurrentBountyTotal = bountyTotal;
setCurrentBountyTotal(liveCurrentBountyTotal);
}
} catch (err) {
console.warn('[TTR] Bounty fetch warning:', err);
}
try {
const mugged90d = await fetchMuggedLast90dById(targetId);
if (typeof mugged90d === 'number') {
liveMuggedLast90d = mugged90d;
setMuggedLast90d(liveMuggedLast90d);
}
} catch (err) {
console.warn('[TTR] 90d mugged fetch warning:', err);
}
const merged = {
friendEnemy: liveFriendEnemy,
te: liveTe,
currentBountyTotal: typeof liveCurrentBountyTotal === 'number' ? liveCurrentBountyTotal : null,
muggedLast90d: typeof liveMuggedLast90d === 'number' ? liveMuggedLast90d : null
};
if (!liveFriendEnemy && !liveTe && liveCurrentBountyTotal == null && liveMuggedLast90d == null) {
return null;
}
setCacheValue(CACHE_KEYS.SUPPLEMENTAL, cacheKey, merged);
pruneBucket(CACHE_KEYS.SUPPLEMENTAL, 1000);
try {
await pushSnapshotUpdate(
targetId,
currentTargetName,
liveFriendEnemy,
liveTe,
typeof liveCurrentBountyTotal === 'number' ? liveCurrentBountyTotal : undefined,
typeof liveMuggedLast90d === 'number' ? liveMuggedLast90d : undefined
);
} catch (err) {
console.warn('[TTR] Snapshot push warning:', err);
}
return merged;
}
function updatePrivateProfileCacheFromResponse(viewerId, targetId, responseData) {
if (!viewerId || !targetId || !responseData) return;
const cacheKey = getViewerScopedProfileCacheKey(viewerId, targetId);
setCacheValue(CACHE_KEYS.PRIVATE_PROFILE, cacheKey, responseData);
pruneBucket(CACHE_KEYS.PRIVATE_PROFILE, 1500);
}
function readPrivateProfileCache(viewerId, targetId) {
if (!viewerId || !targetId) return null;
return getCacheValue(CACHE_KEYS.PRIVATE_PROFILE, getViewerScopedProfileCacheKey(viewerId, targetId));
}
function invalidatePrivateProfileCache(viewerId, targetId) {
if (!viewerId || !targetId) return;
deleteCacheValue(CACHE_KEYS.PRIVATE_PROFILE, getViewerScopedProfileCacheKey(viewerId, targetId));
}
async function refreshProfileCard(options = {}) {
if (!currentTargetId) return;
const preferCache = options.preferCache !== false;
setStatus('');
setDisabled(true);
try {
const apiKey = getEffectiveTornApiKey();
if (!apiKey) {
setDisabled(true);
openTornKeyPrompt();
return;
}
const self = await getSelfProfile().catch(() => null);
const viewerId = Number(self?.id || 0);
if (preferCache && viewerId) {
const cached = readPrivateProfileCache(viewerId, currentTargetId);
if (cached) {
renderCard(cached);
if (isSelfTarget()) {
setActionsVisible(false);
setSelfVotesButtonVisible(true);
} else {
setActionsVisible(true);
setSelfVotesButtonVisible(false);
}
setStatus('', false);
setDisabled(false);
return;
}
}
const res = await gmPostJson(`${WORKER_BASE}/private/profile`, {
apiKey,
targetId: currentTargetId,
targetName: currentTargetName || '',
scriptVersion: SCRIPT_VERSION
});
if (!res.ok || !res.data?.ok) {
throw new Error(res.data?.error || `Profile load failed (${res.status})`);
}
renderCard(res.data);
if (viewerId) {
updatePrivateProfileCacheFromResponse(viewerId, currentTargetId, res.data);
}
if (isSelfTarget()) {
setActionsVisible(false);
setSelfVotesButtonVisible(true);
setDisabled(false);
} else {
setActionsVisible(true);
setSelfVotesButtonVisible(false);
setStatus('', false);
setDisabled(false);
}
} catch (err) {
console.error('[TTR] Profile error:', err);
setStatus('Load failed', false);
setActionsVisible(true);
setSelfVotesButtonVisible(false);
setDisabled(true);
}
}
function renderCard(data) {
const resolvedName = String(data?.target?.name || '').trim() || currentTargetName || `#${currentTargetId}`;
currentTargetName = resolvedName;
currentViewerVote = data?.viewerVote ?? null;
currentViewerMugReport = data?.viewerMugReport ?? null;
if (nameEl) nameEl.textContent = resolvedName;
setScore(data?.score ?? 0);
setVote(data?.viewerVote ?? null);
setMugReports(data?.mugReports ?? 0);
setMugTopReason(data?.mugReportTopSubtype ?? null);
setFriendEnemy(data?.friendEnemy ?? null);
setTe(data?.te ?? null);
setTradeHistorySeen(data?.tradeHistorySeen ?? 0);
setCurrentBountyTotal(Number(data?.currentBountyTotal || 0));
setMuggedLast90d(data?.muggedLast90d == null ? null : Number(data?.muggedLast90d || 0));
setRiskLabel(data);
updateCollapsedSummary();
}
async function submitVote(vote, options = {}) {
if (!currentTargetId) return;
const self = await getSelfProfile().catch(() => null);
const viewerId = Number(self?.id || 0);
if (isSelfTarget()) {
setActionsVisible(false);
setSelfVotesButtonVisible(true);
setDisabled(false);
return;
}
const mugReportSubtype = String(options?.mugReportSubtype || '').trim();
const shownVote = currentViewerVote == null ? 0 : Number(currentViewerVote);
if (shownVote === vote && !mugReportSubtype) {
vote = 0;
}
setDisabled(true);
try {
const apiKey = getEffectiveTornApiKey();
if (!apiKey) {
openTornKeyPrompt();
return;
}
const res = await gmPostJson(`${WORKER_BASE}/vote`, {
apiKey,
targetId: currentTargetId,
targetName: currentTargetName || '',
vote,
scriptVersion: SCRIPT_VERSION
});
if (!res.ok || !res.data?.ok) {
throw new Error(res.data?.error || `Vote failed (${res.status})`);
}
setScore(res.data?.score ?? 0);
setVote(res.data?.viewerVote ?? null);
if (res.data?.friendEnemy) setFriendEnemy(res.data.friendEnemy);
if (res.data?.te) setTe(res.data.te);
if (typeof res.data?.mugReports !== 'undefined') setMugReports(res.data.mugReports);
if (typeof res.data?.mugReportTopSubtype !== 'undefined') setMugTopReason(res.data.mugReportTopSubtype);
if (typeof res.data?.tradeHistorySeen !== 'undefined') setTradeHistorySeen(res.data.tradeHistorySeen);
if (typeof res.data?.currentBountyTotal === 'number') setCurrentBountyTotal(res.data.currentBountyTotal);
if (typeof res.data?.muggedLast90d === 'number') setMuggedLast90d(res.data.muggedLast90d);
let cachePayload = {
ok: true,
viewer: self ? {
id: self.id,
name: self.name,
level: self.level ?? null,
status: self.status ?? null
} : null,
target: {
id: currentTargetId,
name: currentTargetName || `#${currentTargetId}`
},
score: res.data?.score ?? 0,
upvotes: res.data?.upvotes ?? null,
downvotes: res.data?.downvotes ?? null,
viewerVote: res.data?.viewerVote ?? null,
mugReports: res.data?.mugReports ?? 0,
viewerMugReport: res.data?.viewerMugReport ?? null,
mugReportTopSubtype: res.data?.mugReportTopSubtype ?? null,
friendEnemy: res.data?.friendEnemy ?? null,
te: res.data?.te ?? null,
faction: res.data?.faction ?? null,
protection: res.data?.protection ?? null,
tradeHistorySeen: res.data?.tradeHistorySeen ?? 0,
currentBountyTotal: res.data?.currentBountyTotal ?? 0,
muggedLast90d: res.data?.muggedLast90d ?? null
};
if (mugReportSubtype && vote === -1) {
const mugRes = await gmPostJson(`${WORKER_BASE}/mug-report`, {
apiKey,
targetId: currentTargetId,
targetName: currentTargetName || '',
subtype: mugReportSubtype,
scriptVersion: SCRIPT_VERSION
});
if (!mugRes.ok || !mugRes.data?.ok) {
throw new Error(mugRes.data?.error || `Mug report failed (${mugRes.status})`);
}
currentViewerMugReport = mugRes.data?.viewerMugReport ?? null;
if (typeof mugRes.data?.mugReports !== 'undefined') setMugReports(mugRes.data.mugReports);
if (typeof mugRes.data?.mugReportTopSubtype !== 'undefined') setMugTopReason(mugRes.data.mugReportTopSubtype);
cachePayload.viewerMugReport = mugRes.data?.viewerMugReport ?? null;
cachePayload.mugReports = mugRes.data?.mugReports ?? cachePayload.mugReports;
cachePayload.mugReportTopSubtype = mugRes.data?.mugReportTopSubtype ?? cachePayload.mugReportTopSubtype;
} else if (typeof res.data?.viewerMugReport !== 'undefined') {
cachePayload.viewerMugReport = res.data?.viewerMugReport ?? null;
}
setRiskLabel(cachePayload);
if (viewerId) {
updatePrivateProfileCacheFromResponse(viewerId, currentTargetId, cachePayload);
}
setStatus('');
updateCollapsedSummary();
} catch (err) {
console.error('[TTR] Vote error:', err);
setStatus(err?.message || 'Vote failed', true);
if (viewerId) invalidatePrivateProfileCache(viewerId, currentTargetId);
} finally {
if (!isSelfTarget()) setDisabled(false);
}
}
async function refreshForCurrentRoute() {
const profile = isProfilePage();
const trade = isTradeRelevantPage();
if (!profile && !trade) {
removeCard();
currentRouteKey = '';
currentTargetId = null;
currentTargetName = '';
currentTradeId = null;
currentContextType = null;
currentViewerVote = null;
currentViewerMugReport = null;
return;
}
let contextType = null;
let targetId = null;
let targetName = '';
let tradeId = null;
if (profile) {
contextType = 'profile';
targetId = getProfileTargetId();
targetName = getProfileTargetName();
} else if (trade) {
contextType = 'trade';
targetId = await getTradeTargetId().catch((err) => {
console.error('[TTR] Trade target detect error:', err);
return null;
});
targetName = getTradeTargetNameFromDom();
tradeId = getTradeIdFromHash();
}
if (!targetId) return;
if (!ensureMounted(contextType)) return;
const routeKey = `${contextType}:${targetId}:${tradeId || ''}`;
const changed = routeKey !== currentRouteKey || currentContextType !== contextType;
currentRouteKey = routeKey;
currentTargetId = targetId;
currentTargetName = String(targetName || '').trim();
currentTradeId = tradeId || null;
currentContextType = contextType;
if (!changed) return;
currentViewerVote = null;
currentViewerMugReport = null;
if (nameEl) nameEl.textContent = currentTargetName || `#${targetId}`;
setScore('-');
setVote(0);
setMugReports(0);
setMugTopReason(null);
setFriendEnemy(null);
setTe(null);
setTradeHistorySeen(0);
setCurrentBountyTotal(0);
setMuggedLast90d(null);
setStatus('', false);
setActionsVisible(true);
setSelfVotesButtonVisible(false);
applyRiskTone('neutral');
if (riskLabelEl) {
riskLabelEl.textContent = 'Neutral';
riskLabelEl.classList.remove('pos', 'neg', 'neutral');
riskLabelEl.classList.add('neutral');
}
updateCollapsedSummary();
await refreshProfileCard({ preferCache: true });
const supplemental = await refreshSupplementalData(targetId, { preferCache: true }).catch((err) => {
console.warn('[TTR] Supplemental data refresh warning:', err);
return null;
});
if (supplemental) {
const cached = readPrivateProfileCache(Number(selfProfileCache?.id || 0), targetId);
const merged = {
...(cached || {}),
friendEnemy: supplemental.friendEnemy ?? cached?.friendEnemy ?? null,
te: supplemental.te ?? cached?.te ?? null,
currentBountyTotal: supplemental.currentBountyTotal ?? cached?.currentBountyTotal ?? 0,
muggedLast90d: supplemental.muggedLast90d ?? cached?.muggedLast90d ?? null,
viewerVote: currentViewerVote
};
setRiskLabel(merged);
updateCollapsedSummary();
}
}
function removeCard() {
const existing = document.getElementById('ttr-mount-row');
if (existing) existing.remove();
closeAnyModalById('ttr-modal-backdrop');
closeAnyModalById('ttr-torn-key-backdrop');
closeAnyModalById('ttr-mug-backdrop');
closeAnyModalById('ttr-myvotes-backdrop');
rootEl = null;
bodyEl = null;
collapsedSummaryEl = null;
mugRowEl = null;
nameEl = null;
scoreEl = null;
myVoteEl = null;
mugReportsEl = null;
mugTopReasonEl = null;
tradeHistoryEl = null;
statusEl = null;
btnUp = null;
btnDown = null;
actionsEl = null;
friendEnemyEl = null;
teEl = null;
settingsBtn = null;
selfVotesBtn = null;
riskLabelEl = null;
currentBountyEl = null;
mug90dEl = null;
collapseBtn = null;
}
function scheduleEventsSync(force) {
if (!isEventsPage()) return;
const headRef = getEventsHeadRef();
if (!headRef) return;
const prevHeadRef = getPersistentValue(STORAGE_KEY_LAST_EVENTS_HEAD) || '';
const now = Date.now();
if (!force) {
if (headRef === prevHeadRef) return;
if (now - lastEventsSyncAt < EVENT_SYNC_DEBOUNCE_MS) return;
}
lastEventsSyncAt = now;
setPersistentValue(STORAGE_KEY_LAST_EVENTS_HEAD, headRef);
syncEventsPageEvidence().catch((err) => {
console.warn('[TTR] Events sync warning:', err);
});
}
async function syncEventsPageEvidence() {
if (!isEventsPage()) return;
const apiKey = getEffectiveTornApiKey();
if (!apiKey) return;
const self = await getSelfProfile().catch(() => null);
const viewerId = Number(self?.id || 0);
if (!viewerId) return;
const parsedEvents = await parseEventsPageEvidence(viewerId);
if (!parsedEvents.length) return;
const unsynced = parsedEvents.filter(ev => ev?.eventRef && !syncedEventRefs.has(ev.eventRef));
if (!unsynced.length) return;
const res = await gmPostJson(`${WORKER_BASE}/event-evidence/batch`, {
apiKey,
scriptVersion: SCRIPT_VERSION,
events: unsynced
});
if (!res.ok || !res.data?.ok) {
throw new Error(res.data?.error || `Event evidence sync failed (${res.status})`);
}
unsynced.forEach(ev => syncedEventRefs.add(ev.eventRef));
if (syncedEventRefs.size > MAX_SYNCED_EVENT_REFS) {
const arr = Array.from(syncedEventRefs).slice(-800);
syncedEventRefs = new Set(arr);
}
saveSyncedEventRefs(syncedEventRefs);
}
function getEventsHeadRef() {
const firstItem = document.querySelector('ul[class*="eventsList"] li[class*="listItemWrapper"]');
if (!firstItem) return '';
const msgEl = firstItem.querySelector('p[class*="message"]');
const timeEl = firstItem.querySelector('time');
const msg = normalizeSpaces(msgEl?.textContent || '');
const timeText = extractTimeTextFromTimeElement(timeEl);
return `${timeText}||${msg}`.trim();
}
async function parseEventsPageEvidence(viewerId) {
const items = Array.from(document.querySelectorAll('ul[class*="eventsList"] li[class*="listItemWrapper"]'));
if (!items.length) return [];
const out = [];
const mugEvents = [];
const saleCandidates = [];
for (const item of items) {
const msgEl = item.querySelector('p[class*="message"]');
const timeEl = item.querySelector('time');
if (!msgEl || !timeEl) continue;
const fullText = normalizeSpaces(msgEl.textContent);
const eventAt = parseEventTimestampToUnix(timeEl);
if (!eventAt) continue;
const anchors = Array.from(msgEl.querySelectorAll('a[href]'));
const profileAnchors = anchors
.map(a => ({
href: String(a.getAttribute('href') || ''),
text: normalizeSpaces(a.textContent),
id: extractXidFromHref(String(a.getAttribute('href') || ''))
}))
.filter(a => Number.isInteger(a.id) && a.id > 0);
if (/has initiated a trade titled/i.test(fullText)) {
const tradeAnchor = anchors.find(a => /trade\.php/i.test(String(a.getAttribute('href') || '')));
const tradeId = extractTradeIdFromHref(String(tradeAnchor?.getAttribute('href') || ''));
const target = profileAnchors[0];
if (target && target.id !== viewerId) {
const eventRef = tradeId
? `trade_initiated:${target.id}:${tradeId}`
: `trade_initiated:${target.id}:${eventAt}`;
out.push({
kind: 'trade_initiated',
targetId: target.id,
targetName: target.text || '',
eventAt,
tradeId: tradeId || null,
attackLogId: null,
eventRef
});
}
continue;
}
const saleCandidate = await parseSaleBuyerCandidate(fullText, eventAt);
if (saleCandidate) {
saleCandidates.push(saleCandidate);
continue;
}
if (/mugged you\b/i.test(fullText)) {
const attackAnchor = anchors.find(a => /page\.php\?sid=attackLog/i.test(String(a.getAttribute('href') || '')));
const attackLogId = extractAttackLogIdFromHref(String(attackAnchor?.getAttribute('href') || ''));
const muggerAnchors = profileAnchors.filter(a => a.id !== viewerId);
mugEvents.push({
eventAt,
attackLogId: attackLogId || null,
directMuggerId: muggerAnchors.length ? muggerAnchors[0].id : null,
directMuggerName: muggerAnchors.length ? (muggerAnchors[0].text || '') : ''
});
}
}
for (const mug of mugEvents) {
const nearbySales = saleCandidates.filter(c =>
mug.eventAt >= c.eventAt &&
(mug.eventAt - c.eventAt) <= SUSPICIOUS_MUG_LINK_WINDOW_SEC
);
if (!nearbySales.length) continue;
let matchedCandidate = null;
if (mug.directMuggerId) {
const exactMatches = nearbySales.filter(c => c.targetId === mug.directMuggerId);
if (exactMatches.length !== 1) continue;
matchedCandidate = exactMatches[0];
} else {
const uniqueIds = [...new Set(nearbySales.map(c => c.targetId))];
if (uniqueIds.length !== 1) continue;
matchedCandidate = nearbySales
.filter(c => c.targetId === uniqueIds[0])
.sort((a, b) => b.eventAt - a.eventAt)[0];
}
const eventRef = mug.attackLogId
? `mugged_you_linked:${matchedCandidate.targetId}:${mug.attackLogId}`
: `mugged_you_linked:${matchedCandidate.targetId}:${mug.eventAt}`;
out.push({
kind: 'mugged_you',
targetId: matchedCandidate.targetId,
targetName: matchedCandidate.targetName || mug.directMuggerName || '',
eventAt: mug.eventAt,
tradeId: null,
attackLogId: mug.attackLogId || null,
eventRef
});
}
return dedupeEvents(out);
}
async function parseSaleBuyerCandidate(fullText, eventAt) {
let m = fullText.match(/on the Item Market to\s+([A-Za-z0-9_.\-\[\]\s]+?)\s+for\s+\$/i);
if (m) {
const targetName = normalizeSpaces(m[1]);
const resolved = await resolveUserByName(targetName).catch(() => null);
if (resolved?.id) {
return {
kind: 'sale_candidate_market',
targetId: Number(resolved.id),
targetName: resolved.name || targetName,
eventAt
};
}
}
m = fullText.match(/^(.+?)\s+bought\s+.+?\s+from your bazaar\b/i);
if (m) {
const targetName = normalizeSpaces(m[1]);
const resolved = await resolveUserByName(targetName).catch(() => null);
if (resolved?.id) {
return {
kind: 'sale_candidate_bazaar',
targetId: Number(resolved.id),
targetName: resolved.name || targetName,
eventAt
};
}
}
return null;
}
function dedupeEvents(events) {
const seen = new Set();
const out = [];
for (const ev of events) {
const key = String(ev?.eventRef || '');
if (!key || seen.has(key)) continue;
seen.add(key);
out.push(ev);
}
return out;
}
function normalizeSpaces(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function extractXidFromHref(href) {
const m = String(href || '').match(/XID=(\d+)/i);
return m ? Number(m[1]) : null;
}
function extractTradeIdFromHref(href) {
const m = String(href || '').match(/(?:^|[?#&])ID=(\d+)/i);
return m ? String(m[1]) : null;
}
function extractAttackLogIdFromHref(href) {
const s = String(href || '');
let m = s.match(/attackLog&ID=([A-Za-z0-9]+)/i);
if (m) return String(m[1]);
m = s.match(/attackLog.*?[?&]ID=([A-Za-z0-9]+)/i);
if (m) return String(m[1]);
m = s.match(/[?&]ID=([A-Za-z0-9]+)/i);
return m ? String(m[1]) : null;
}
function extractTimeTextFromTimeElement(timeEl) {
if (!timeEl) return '';
const direct =
timeEl.getAttribute?.('datetime') ||
timeEl.getAttribute?.('title') ||
timeEl.getAttribute?.('aria-label') ||
'';
if (direct && /(\d{1,2}):(\d{2}):(\d{2}).*?(\d{1,2})\/(\d{1,2})\/(\d{2,4})/.test(direct)) {
return direct;
}
const cloned = timeEl.cloneNode(true);
const text = normalizeSpaces(cloned.textContent || '');
if (text) return text;
return normalizeSpaces(timeEl.innerText || '');
}
function getCurrentTctUnixApprox() {
const bodyText = normalizeSpaces(document.body?.innerText || document.body?.textContent || '');
const m = bodyText.match(/(\d{1,2}):(\d{2}):(\d{2})\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
if (m) {
let yy = Number(m[6]);
if (yy < 100) yy += 2000;
const utcMs = Date.UTC(
yy,
Number(m[5]) - 1,
Number(m[4]),
Number(m[1]),
Number(m[2]),
Number(m[3])
);
if (Number.isFinite(utcMs)) {
return Math.floor(utcMs / 1000);
}
}
return Math.floor(Date.now() / 1000);
}
function parseEventTimestampToUnix(timeEl) {
const text = extractTimeTextFromTimeElement(timeEl);
if (!text) return null;
const m = text.match(/(\d{1,2}):(\d{2}):(\d{2})\s*(?:-\s*)?(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
if (!m) return null;
let yy = Number(m[6]);
if (yy < 100) yy += 2000;
const rawUtc = Math.floor(Date.UTC(
yy,
Number(m[5]) - 1,
Number(m[4]),
Number(m[1]),
Number(m[2]),
Number(m[3])
) / 1000);
const approxNowTct = getCurrentTctUnixApprox();
const approxNowReal = Math.floor(Date.now() / 1000);
const offset = approxNowReal - approxNowTct;
return rawUtc + offset;
}
function toNullableInt(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isInteger(n) ? n : null;
}
function escapeHtmlAttr(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
pruneBucket(CACHE_KEYS.SELF_PROFILE, 3);
pruneBucket(CACHE_KEYS.PRIVATE_PROFILE, 1500);
pruneBucket(CACHE_KEYS.SUPPLEMENTAL, 1000);
pruneBucket(CACHE_KEYS.RESOLVE_USER, 500);
})();