// ==UserScript==
// @name Torn Travel Widget
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description Travel status widget with integrated API settings for Torn.com header, with robust API key management for desktop and Torn PDA.
// @author TheProgrammer [2782979] - https://www.torn.com/profiles.php?XID=2782979
// @match *://*.torn.com/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @connect api.torn.com
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- PROMISE-BASED GM STORAGE & HELPERS ---
const GM_get = (key, def) => new Promise(resolve => resolve(GM_getValue(key, def)));
const GM_set = (key, val) => new Promise(resolve => resolve(GM_setValue(key, val)));
const GM_del = (key) => new Promise(resolve => resolve(GM_deleteValue(key)));
const API_KEY_STORAGE_KEY = 'torn_travel_widget_api_key';
const PDA_API_KEY_PLACEHOLDER = '###PDA-APIKEY###';
// --- PDA DETECTION ---
const isPDA = typeof window.flutter_inappwebview !== 'undefined' &&
typeof window.flutter_inappwebview.callHandler === 'function';
// --- STYLES ---
GM_addStyle(`
.ttw-header-button-li { position: relative; }
.ttw-popup {
position: fixed; top: 60px; right: 10px;
width: 350px; background-color: #333; border: 1px solid #555;
border-radius: 5px; box-shadow: 0 5px 15px rgba(0,0,0,0.5);
z-index: 10000; color: #ccc; font-family: 'Signika', 'Verdana', sans-serif;
padding: 10px; display: block;
}
.ttw-popup-hidden { display: none !important; }
.ttw-popup-content { font-size: 12px; }
.ttw-popup-content .error { color: #ff6347; }
.ttw-popup-content .success { color: #90ee90; }
.ttw-tab-container { margin-bottom: 10px; }
.ttw-tab-buttons { display: flex; border-bottom: 1px solid #444; margin-bottom: 10px; }
.ttw-tab-button {
flex: 1; padding: 8px 12px; background: transparent; border: none;
color: #ccc; cursor: pointer; font-size: 11px; border-bottom: 2px solid transparent;
}
.ttw-tab-button.active { color: #fff; border-bottom-color: #4CAF50; }
.ttw-tab-button:hover { background-color: #444; }
.ttw-tab-content { display: none; }
.ttw-tab-content.active { display: block; }
.ttw-settings-tab .tos-table { width: 100%; border-collapse: collapse; font-size: 10px; margin: 10px 0; }
.ttw-settings-tab .tos-table th, .ttw-settings-tab .tos-table td { border: 1px solid #555; padding: 4px; text-align: left; }
.ttw-settings-tab input[type="password"] {
width: 100%; padding: 6px; border-radius: 3px; border: 1px solid #555;
background-color: #222; color: white; margin-bottom: 10px; box-sizing: border-box;
}
.ttw-settings-tab .api-button-group { display: flex; gap: 8px; justify-content: flex-end; }
.ttw-settings-tab .api-button { padding: 5px 10px; border: none; border-radius: 3px; cursor: pointer; font-size: 11px; }
.ttw-settings-tab .save-key { background-color: #4CAF50; color: white; }
.ttw-settings-tab .clear-key { background-color: #f44336; color: white; }
.ttw-settings-tab .pda-note { color: #87ceeb; font-size: 11px; margin-top: -5px; margin-bottom: 10px; }
`);
// --- API & DATA MANAGER ---
const ApiManager = {
apiKey: null,
async setup() {
let storedKey = await GM_get(API_KEY_STORAGE_KEY, null);
if (!storedKey && isPDA) {
await GM_set(API_KEY_STORAGE_KEY, PDA_API_KEY_PLACEHOLDER);
storedKey = PDA_API_KEY_PLACEHOLDER;
}
this.apiKey = storedKey;
},
fetchTornApi(selections, id = '') {
return new Promise((resolve, reject) => {
let keyToUse = this.apiKey;
if (keyToUse === PDA_API_KEY_PLACEHOLDER) {
if (window.tornium && window.tornium.apiKey) {
keyToUse = window.tornium.apiKey;
} else { return reject(new Error("Torn PDA key not found.")); }
}
if (!keyToUse) { return reject(new Error("API Key not set. Use the Settings tab to add one.")); }
const url = `https://api.torn.com/user/${id}?selections=${selections}&key=${keyToUse}`;
GM_xmlhttpRequest({
method: 'GET', url: url,
onload: (response) => {
const json = JSON.parse(response.responseText);
if (json.error) return reject(json.error);
resolve(json);
},
onerror: (error) => reject(error)
});
});
}
};
// --- HEADER BUTTON MANAGER ---
const HeaderButtonManager = {
buttons: [],
activePopup: null,
activeButton: null,
register(buttonDef) { this.buttons.push(buttonDef); },
async initialize() {
const targetUl = await this.waitForElement('.header-buttons-wrapper ul.toolbar');
if (!targetUl) return;
const insertionPoint = targetUl.querySelector('.tc-clock');
this.buttons.forEach(buttonDef => {
const { li, button } = this.createButtonElements(buttonDef);
targetUl.insertBefore(li, insertionPoint);
button.addEventListener('click', (e) => {
e.stopPropagation();
this.togglePopup(li.querySelector('.ttw-popup'), buttonDef, button);
});
});
},
createButtonElements(buttonDef) {
const li = document.createElement('li');
li.className = `ttw-header-button-li ${buttonDef.id}`;
const button = document.createElement('button');
button.type = 'button';
button.className = `top_header_button button ${buttonDef.id}`;
button.setAttribute('aria-label', `Open ${buttonDef.name}`);
button.innerHTML = buttonDef.icon;
const popup = document.createElement('div');
popup.className = 'ttw-popup ttw-popup-hidden';
popup.id = `ttw-popup-${buttonDef.id}`;
popup.innerHTML = `<div class="ttw-popup-content">Loading...</div>`;
popup.addEventListener('click', e => e.stopPropagation());
li.appendChild(button);
li.appendChild(popup);
return { li, button };
},
togglePopup(popup, buttonDef, button) {
const isOpening = popup.classList.contains('ttw-popup-hidden');
if (isOpening) {
// Opening popup
this.hideAllPopups(); // Close any other popups first
popup.classList.remove('ttw-popup-hidden');
this.activePopup = popup;
this.activeButton = button;
if (typeof buttonDef.updateFunction === 'function') {
const contentElement = popup.querySelector('.ttw-popup-content');
buttonDef.updateFunction(contentElement);
}
} else {
// Closing popup
this.hideAllPopups();
}
},
hideAllPopups() {
document.querySelectorAll('.ttw-popup').forEach(p => p.classList.add('ttw-popup-hidden'));
this.activePopup = null;
this.activeButton = null;
},
waitForElement(selector) {
return new Promise(resolve => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) { resolve(el); observer.disconnect(); }
});
observer.observe(document.body, { childList: true, subtree: true });
});
}
};
// --- BUTTON DEFINITIONS ---
/**
* Travel Widget with integrated API Settings
* Uses the "airplane" icon and contains both travel info and settings tabs.
*/
const travelWidget = {
id: "ttw-travel-widget",
name: "Travel Widget",
countdown: null,
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="default___XXAGt" width="28" height="28" viewBox="0 0 30 30" fill="#fff"><path d="M22 2L15 22L11 13L2 9L22 2Z"></path></svg>`,
async updateFunction(contentElement) {
// Create tabbed interface without title
contentElement.innerHTML = `
<div class="ttw-tab-container">
<div class="ttw-tab-buttons">
<button class="ttw-tab-button active" data-tab="travel">Travel</button>
<button class="ttw-tab-button" data-tab="settings">Settings</button>
</div>
<div class="ttw-tab-content active" id="ttw-travel-tab">
<div id="ttw-travel-content">Loading travel status...</div>
</div>
<div class="ttw-tab-content ttw-settings-tab" id="ttw-settings-tab">
<div id="ttw-settings-content">Loading settings...</div>
</div>
</div>
`;
// Set up tab switching
const tabButtons = contentElement.querySelectorAll('.ttw-tab-button');
const tabContents = contentElement.querySelectorAll('.ttw-tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabName = button.dataset.tab;
// Update button states
tabButtons.forEach(b => b.classList.remove('active'));
button.classList.add('active');
// Update content visibility
tabContents.forEach(content => content.classList.remove('active'));
contentElement.querySelector(`#ttw-${tabName}-tab`).classList.add('active');
// Load content for the active tab
if (tabName === 'travel') {
this.loadTravelContent();
} else if (tabName === 'settings') {
this.loadSettingsContent();
}
});
});
// Load initial travel content
this.loadTravelContent();
},
async loadTravelContent() {
const travelContent = document.querySelector('#ttw-travel-content');
if (!travelContent) return;
try {
const data = await ApiManager.fetchTornApi('travel');
// Check if user is in Torn City or already arrived
if (data.travel.destination === "Torn" || data.travel.time_left === 0) {
travelContent.innerHTML = `<span class="success">Currently in Torn City.</span>`;
// Clear any existing countdown
if (this.countdown) {
clearInterval(this.countdown);
this.countdown = null;
}
} else {
// Start live countdown using the timestamp (arrival time)
this.startLiveCountdown(travelContent, data.travel.timestamp, data.travel.destination);
}
} catch (error) {
travelContent.innerHTML = `<span class="error">API Error: ${error.error || error.message}</span>`;
// Clear any existing countdown on error
if (this.countdown) {
clearInterval(this.countdown);
this.countdown = null;
}
}
},
startLiveCountdown(el, arrivalTimestamp, destination) {
// Clear any existing countdown
if (this.countdown) {
clearInterval(this.countdown);
}
const updateCountdown = () => {
// Get current time in seconds (Unix timestamp)
const currentTime = Math.floor(Date.now() / 1000);
// Calculate seconds remaining until arrival
const secondsLeft = arrivalTimestamp - currentTime;
if (secondsLeft <= 0) {
el.innerHTML = `<span class="success">Arrived in ${destination}!</span>`;
clearInterval(this.countdown);
this.countdown = null;
} else {
// Convert seconds to hours, minutes, seconds
const hours = Math.floor(secondsLeft / 3600);
const minutes = Math.floor((secondsLeft % 3600) / 60);
const seconds = secondsLeft % 60;
// Format with leading zeros
const hoursStr = String(hours).padStart(2, '0');
const minutesStr = String(minutes).padStart(2, '0');
const secondsStr = String(seconds).padStart(2, '0');
el.innerHTML = `Flying to <b>${destination}</b>.<br>Arrives in: ${hoursStr}:${minutesStr}:${secondsStr}`;
}
};
// Update immediately, then every second
updateCountdown();
this.countdown = setInterval(updateCountdown, 1000);
},
async loadSettingsContent() {
const settingsContent = document.querySelector('#ttw-settings-content');
if (!settingsContent) return;
const currentKey = await GM_get(API_KEY_STORAGE_KEY, '');
let contentHTML = `
<p style="font-size: 11px;">Manage your API key. The key is stored locally in your browser and is never shared.</p>
<table class="tos-table">
<thead><tr><th>Key Access Level</th><th>Purpose of Use</th></tr></thead>
<tbody><tr><td>Limited Access</td><td>Non-malicious statistical analysis for widgets</td></tr></tbody>
</table>
<input type="password" id="ttw-api-key-input" placeholder="Enter your Limited Access API Key" value="${currentKey === PDA_API_KEY_PLACEHOLDER ? '' : currentKey}">
`;
if (currentKey === PDA_API_KEY_PLACEHOLDER) {
contentHTML += `<p class="pda-note">Using Torn PDA managed API key. No entry needed.</p>`;
}
contentHTML += `
<div class="api-button-group">
<button class="api-button clear-key">Clear Key</button>
<button class="api-button save-key">Save Key</button>
</div>
<div id="ttw-api-status" style="font-size: 11px; margin-top: 8px;"></div>
`;
settingsContent.innerHTML = contentHTML;
const input = settingsContent.querySelector('#ttw-api-key-input');
const saveBtn = settingsContent.querySelector('.save-key');
const clearBtn = settingsContent.querySelector('.clear-key');
const statusDiv = settingsContent.querySelector('#ttw-api-status');
if (currentKey === PDA_API_KEY_PLACEHOLDER) {
input.disabled = true;
saveBtn.disabled = true;
}
saveBtn.onclick = async () => {
const newKey = input.value.trim();
if (newKey) {
await GM_set(API_KEY_STORAGE_KEY, newKey);
ApiManager.apiKey = newKey;
statusDiv.innerHTML = `<span class="success">API Key saved successfully!</span>`;
// Refresh travel tab if it was showing an error
const activeTab = document.querySelector('.ttw-tab-button.active');
if (activeTab && activeTab.dataset.tab === 'travel') {
this.loadTravelContent();
}
} else {
statusDiv.innerHTML = `<span class="error">API Key cannot be empty.</span>`;
}
setTimeout(() => statusDiv.innerHTML = '', 3000);
};
clearBtn.onclick = async () => {
if (confirm('Are you sure you want to clear your stored API key?')) {
await GM_del(API_KEY_STORAGE_KEY);
ApiManager.apiKey = null;
input.value = '';
input.disabled = false;
saveBtn.disabled = false;
settingsContent.querySelector('.pda-note')?.remove();
statusDiv.innerHTML = `<span class="success">API Key cleared.</span>`;
setTimeout(() => statusDiv.innerHTML = '', 3000);
}
};
}
};
// --- SCRIPT INITIALIZATION ---
async function main() {
await ApiManager.setup();
HeaderButtonManager.register(travelWidget);
HeaderButtonManager.initialize();
}
main();
})();