// ==UserScript==
// @name Torn Mail Templates
// @namespace https://github.com/GlasnostDev/torn-mail-templates
// @version 2.0.1
// @description Mail templates, quick send, mobile support, sent log.
// @author Glasnost [1844049]
// @match https://www.torn.com/messages.php*
// @match https://www.torn.com/page.php?sid=UserList*
// @updateURL https://raw.githubusercontent.com/GlasnostDev/torn-mail-templates/main/torn-mail-templates.user.js
// @downloadURL https://raw.githubusercontent.com/GlasnostDev/torn-mail-templates/main/torn-mail-templates.user.js
// @icon https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant none
// ==/UserScript==
(function() {
'use strict';
const STORAGE_KEY = 'mailTemplates_v1';
const SEND_TIME_KEY = 'mailTemplates_lastSendTime';
const SENT_LOG_KEY = 'mailTemplates_sentLog';
const OPTIONS_KEY = 'mailTemplates_options';
const COOLDOWN_MS = 10000;
const RETENTION_OPTIONS = [1, 3, 7, 14, 30];
function isMobile() { return document.body.classList.contains('tt-mobile') || window.innerWidth < 600; }
// ── Options ──────────────────────────────────────────────────────────────
function getOptions() { try { return JSON.parse(localStorage.getItem(OPTIONS_KEY)) || {}; } catch(e) { return {}; } }
function saveOptions(o) { localStorage.setItem(OPTIONS_KEY, JSON.stringify(o)); }
function getRetentionDays() { return getOptions().retentionDays || 7; }
function getNewTab() { return getOptions().newTab === true; }
// ── Sent log ─────────────────────────────────────────────────────────────
function getSentLog() { try { return JSON.parse(localStorage.getItem(SENT_LOG_KEY)) || {}; } catch(e) { return {}; } }
function saveSentLog(l) { localStorage.setItem(SENT_LOG_KEY, JSON.stringify(l)); }
function recordSent(xid) { if (!xid) return; const l = getSentLog(); l[xid] = Date.now(); saveSentLog(l); }
function pruneSentLog() {
const l = getSentLog(), cutoff = Date.now() - getRetentionDays() * 86400000;
let changed = false;
Object.keys(l).forEach(x => { if (l[x] < cutoff) { delete l[x]; changed = true; } });
if (changed) saveSentLog(l);
}
function recentlySent(xid) { pruneSentLog(); return !!getSentLog()[xid]; }
function clearSentLog() { localStorage.removeItem(SENT_LOG_KEY); }
// ── Templates ────────────────────────────────────────────────────────────
function getTemplates() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } catch(e) { return {}; } }
function saveTemplates(t) { localStorage.setItem(STORAGE_KEY, JSON.stringify(t)); }
function recordSendTime() { localStorage.setItem(SEND_TIME_KEY, Date.now().toString()); }
function getLastSendTime() { const v = localStorage.getItem(SEND_TIME_KEY); return v ? parseInt(v, 10) : null; }
// ── Compose helpers ──────────────────────────────────────────────────────
function getSubject() { const el = document.querySelector('input.subject'); return el ? el.value : ''; }
function getBody() { const el = document.querySelector('[contenteditable="true"]'); return el ? el.innerHTML : ''; }
function setSubject(val) {
const el = document.querySelector('input.subject');
if (el) { el.value = val; el.dispatchEvent(new Event('input', { bubbles: true })); }
}
function setBody(val) {
const el = document.querySelector('[contenteditable="true"]');
if (el) { el.innerHTML = val; el.dispatchEvent(new Event('input', { bubbles: true })); }
}
// ── Shortcodes ───────────────────────────────────────────────────────────
const SHORTCODES = [
{ code: '{{@name}}', description: "Recipient's display name", example: 'Glasnost' },
{ code: '{{@xid}}', description: "Recipient's player ID number", example: '1844049' },
];
function getRecipientInfo() {
const n = document.querySelector('input.user-id'), v = n ? n.value : '';
return {
name: v.replace(/\s*\[\d+\]$/, '').trim(),
xid: (v.match(/\[(\d+)\]/) || [])[1] || ''
};
}
function resolveVars(text) {
const { name, xid } = getRecipientInfo();
return text.replace(/\{\{@name\}\}/gi, name).replace(/\{\{@xid\}\}/gi, xid);
}
// ── Modal helpers ────────────────────────────────────────────────────────
function makeOverlay(id) {
const o = document.createElement('div');
o.id = id;
o.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:99999;display:flex;align-items:center;justify-content:center;';
o.addEventListener('click', e => { if (e.target === o) o.remove(); });
return o;
}
function makeModal() {
const m = document.createElement('div');
m.style.cssText = 'background:#222;border:1px solid #444;border-radius:6px;padding:20px;min-width:300px;max-width:90vw;font-family:Arial,sans-serif;';
return m;
}
function makeTitle(text) {
const h = document.createElement('h3');
h.style.cssText = 'color:#eee;margin:0 0 12px;font-size:14px;';
h.textContent = text;
return h;
}
function makeButton(text, bg, onClick) {
const b = document.createElement('button');
b.style.cssText = 'border:none;border-radius:3px;padding:6px 14px;cursor:pointer;font-size:13px;color:#fff;background:' + bg + ';transition:background 0.2s;';
b.textContent = text;
b.addEventListener('click', onClick);
return b;
}
// ── Export with selection ─────────────────────────────────────────────────
function doExport() {
const templates = getTemplates(), keys = Object.keys(templates);
if (keys.length === 0) { alert('No templates to export.'); return; }
const overlay = makeOverlay('mt-exp-ov'), modal = makeModal();
modal.appendChild(makeTitle('📤 Export Templates'));
const sub = document.createElement('p');
sub.style.cssText = 'color:#666;font-size:11px;margin:0 0 10px;';
sub.textContent = 'Select templates to export:';
modal.appendChild(sub);
const allRow = document.createElement('div'); allRow.style.cssText = 'margin-bottom:8px;display:flex;align-items:center;';
const allCb = document.createElement('input'); allCb.type = 'checkbox'; allCb.checked = true; allCb.style.marginRight = '8px';
const allLbl = document.createElement('label'); allLbl.style.cssText = 'color:#aaa;font-size:12px;cursor:pointer;font-weight:bold;'; allLbl.textContent = 'Select all';
allRow.appendChild(allCb); allRow.appendChild(allLbl); modal.appendChild(allRow);
const hr = document.createElement('hr'); hr.style.cssText = 'border:none;border-top:1px solid #333;margin:0 0 10px;'; modal.appendChild(hr);
const cbContainer = document.createElement('div'); cbContainer.style.cssText = 'max-height:240px;overflow-y:auto;margin-bottom:14px;';
const cbMap = {};
keys.forEach(name => {
const row = document.createElement('div'); row.style.cssText = 'margin-bottom:6px;display:flex;align-items:center;';
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; cb.style.marginRight = '8px';
cb.addEventListener('change', () => { allCb.checked = Object.values(cbMap).every(c => c.checked); });
const lbl = document.createElement('label'); lbl.style.cssText = 'color:#ccc;font-size:12px;cursor:pointer;'; lbl.textContent = name;
cbMap[name] = cb;
row.appendChild(cb); row.appendChild(lbl); cbContainer.appendChild(row);
});
allCb.addEventListener('change', () => Object.values(cbMap).forEach(c => c.checked = allCb.checked));
modal.appendChild(cbContainer);
const footer = document.createElement('div'); footer.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
footer.appendChild(makeButton('Cancel', '#555', () => overlay.remove()));
footer.appendChild(makeButton('📤 Export', '#6a1b9a', () => {
const selected = {};
Object.entries(cbMap).forEach(([name, cb]) => { if (cb.checked) selected[name] = templates[name]; });
if (Object.keys(selected).length === 0) { alert('No templates selected.'); return; }
const json = JSON.stringify(selected, null, 2);
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
const a = document.createElement('a'); a.href = url; a.download = 'mail-templates.json'; a.click();
URL.revokeObjectURL(url); overlay.remove();
}));
modal.appendChild(footer); overlay.appendChild(modal); document.body.appendChild(overlay);
}
// ── Import with conflict resolution ──────────────────────────────────────
function doImport() {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json';
input.addEventListener('change', () => {
const file = input.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = e => {
try {
const imported = JSON.parse(e.target.result);
if (typeof imported !== 'object' || Array.isArray(imported)) { alert('Invalid format.'); return; }
processImport(imported);
} catch(err) { alert('Parse error: ' + err.message); }
};
reader.readAsText(file);
});
input.click();
}
function processImport(imported) {
const existing = getTemplates();
const keys = Object.keys(imported);
const conflicts = keys.filter(k => existing[k]);
const fresh = keys.filter(k => !existing[k]);
if (conflicts.length === 0) {
saveTemplates(Object.assign({}, existing, imported));
buildUI();
alert('Imported ' + keys.length + ' template(s).');
return;
}
const overlay = makeOverlay('mt-imp-ov'), modal = makeModal();
modal.appendChild(makeTitle('📥 Import — Conflicts Found'));
const sub = document.createElement('p'); sub.style.cssText = 'color:#666;font-size:11px;margin:0 0 12px;';
sub.textContent = fresh.length + ' new template(s). ' + conflicts.length + ' conflict(s) with existing names:';
modal.appendChild(sub);
const container = document.createElement('div'); container.style.cssText = 'max-height:220px;overflow-y:auto;margin-bottom:14px;';
const choiceMap = {};
conflicts.forEach(name => {
const row = document.createElement('div'); row.style.cssText = 'margin-bottom:8px;padding:8px;background:#1a1a1a;border-radius:3px;';
const nameRow = document.createElement('div'); nameRow.style.cssText = 'color:#e53935;font-size:12px;font-weight:bold;margin-bottom:6px;';
nameRow.textContent = '⚠️ "' + name + '" already exists';
const sel = document.createElement('select'); sel.style.cssText = 'background:#2a2a2a;color:#ccc;border:1px solid #444;border-radius:3px;padding:4px 6px;font-size:12px;width:100%;';
var opts = [['overwrite', 'Overwrite existing'], ['skip', 'Skip (keep existing)']];
opts.forEach(function(pair) {
const opt = document.createElement('option'); opt.value = pair[0]; opt.textContent = pair[1]; sel.appendChild(opt);
});
choiceMap[name] = sel;
row.appendChild(nameRow); row.appendChild(sel); container.appendChild(row);
});
modal.appendChild(container);
const footer = document.createElement('div'); footer.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
footer.appendChild(makeButton('Cancel', '#555', () => overlay.remove()));
footer.appendChild(makeButton('📥 Import', '#e65100', () => {
const merged = Object.assign({}, existing);
fresh.forEach(k => { merged[k] = imported[k]; });
conflicts.forEach(name => { if (choiceMap[name].value === 'overwrite') merged[name] = imported[name]; });
saveTemplates(merged); buildUI(); overlay.remove();
const ow = conflicts.filter(n => choiceMap[n].value === 'overwrite').length;
const sk = conflicts.filter(n => choiceMap[n].value === 'skip').length;
alert('Import complete. ' + fresh.length + ' new, ' + ow + ' overwritten, ' + sk + ' skipped.');
}));
modal.appendChild(footer); overlay.appendChild(modal); document.body.appendChild(overlay);
}
// ── Send button ──────────────────────────────────────────────────────────
let sendBtnObserver = null, attachedSendBtn = null;
function getSendButton() {
let found = null;
document.querySelectorAll('button.torn-btn').forEach(b => { if (b.textContent.trim() === 'Send') found = b; });
return found;
}
function attachSendListener() {
const btn = getSendButton();
if (btn && btn !== attachedSendBtn) {
attachedSendBtn = btn;
btn.addEventListener('click', () => {
recordSendTime();
const info = getRecipientInfo();
if (info.xid) recordSent(info.xid);
}, true);
}
}
function watchSendButton() {
if (sendBtnObserver) sendBtnObserver.disconnect();
attachSendListener();
sendBtnObserver = new MutationObserver(() => attachSendListener());
sendBtnObserver.observe(document.body, { childList: true, subtree: true });
}
// ── Timers ───────────────────────────────────────────────────────────────
let quickSendInterval = null;
function updateQuickSend() {
const b = document.getElementById('mt-qs-btn'); if (!b) return;
const last = getLastSendTime(), r = last ? COOLDOWN_MS - (Date.now() - last) : 0;
if (r > 0) {
b.disabled = true;
b.textContent = '⏳ Wait ' + (r / 1000).toFixed(1) + 's';
b.style.background = '#555'; b.style.cursor = 'not-allowed';
} else {
b.disabled = false;
b.textContent = '⚡ Quick Send';
b.style.background = '#1565c0'; b.style.cursor = 'pointer';
}
}
function startTimers() {
if (quickSendInterval) clearInterval(quickSendInterval);
quickSendInterval = setInterval(updateQuickSend, 100);
}
// ── Save modal with conflict detection ───────────────────────────────────
function showSaveModal(subject, body) {
const overlay = makeOverlay('mt-save-ov'), modal = makeModal();
modal.appendChild(makeTitle('Save Template'));
const inp = document.createElement('input'); inp.type = 'text'; inp.placeholder = 'Template name...';
inp.style.cssText = 'width:100%;box-sizing:border-box;background:#333;color:#eee;border:1px solid #555;border-radius:3px;padding:8px;font-size:13px;margin-bottom:6px;';
modal.appendChild(inp);
const warn = document.createElement('p'); warn.style.cssText = 'color:#e53935;font-size:11px;margin:0 0 10px;min-height:16px;';
modal.appendChild(warn);
inp.addEventListener('input', () => {
warn.textContent = getTemplates()[inp.value.trim()] ? '⚠️ A template with this name already exists.' : '';
});
const footer = document.createElement('div'); footer.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
footer.appendChild(makeButton('Cancel', '#555', () => overlay.remove()));
const saveBtn = makeButton('Save', '#2e7d32', () => {
const name = inp.value.trim(); if (!name) { alert('Enter a name.'); return; }
const templates = getTemplates();
if (templates[name]) {
showSaveConflict(overlay, name, subject, body);
} else {
templates[name] = { subject: subject, body: body };
saveTemplates(templates); overlay.remove(); buildUI();
}
});
inp.addEventListener('keydown', e => { if (e.key === 'Enter') saveBtn.click(); });
footer.appendChild(saveBtn); modal.appendChild(footer);
overlay.appendChild(modal); document.body.appendChild(overlay);
setTimeout(() => inp.focus(), 50);
}
function showSaveConflict(existingOverlay, name, subject, body) {
existingOverlay.remove();
const overlay = makeOverlay('mt-conf-ov'), modal = makeModal();
modal.appendChild(makeTitle('⚠️ Template Already Exists'));
const msg = document.createElement('p'); msg.style.cssText = 'color:#ccc;font-size:12px;margin:0 0 16px;';
msg.textContent = '"' + name + '" already exists. What would you like to do?';
modal.appendChild(msg);
const footer = document.createElement('div'); footer.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;';
footer.appendChild(makeButton('Overwrite', '#b71c1c', () => {
const t = getTemplates(); t[name] = { subject: subject, body: body }; saveTemplates(t); overlay.remove(); buildUI();
}));
footer.appendChild(makeButton('Change Name', '#37474f', () => { overlay.remove(); showSaveModal(subject, body); }));
footer.appendChild(makeButton('Cancel', '#555', () => overlay.remove()));
modal.appendChild(footer); overlay.appendChild(modal); document.body.appendChild(overlay);
}
// ── Help modal ───────────────────────────────────────────────────────────
function showHelpModal() {
if (document.getElementById('mt-help-ov')) return;
const overlay = makeOverlay('mt-help-ov'), modal = makeModal();
modal.appendChild(makeTitle('📋 Template Shortcodes'));
const sub = document.createElement('p'); sub.style.cssText = 'color:#666;font-size:11px;margin:0 0 12px;';
sub.textContent = 'Replaced with live values when a template is loaded.'; modal.appendChild(sub);
const table = document.createElement('table'); table.style.cssText = 'width:100%;border-collapse:collapse;';
table.innerHTML = '' +
' ';
const tbody = document.createElement('tbody');
SHORTCODES.forEach(function(item, i) {
const border = i < SHORTCODES.length - 1 ? '1px solid #2a2a2a' : 'none';
const tr = document.createElement('tr');
tr.innerHTML = 'Shortcode ' +
'Description ' +
'Example ' +
'