Chathead-style forum reply notifications. Drag to reposition, tap to view.
// ==UserScript==
// @name Torn Forum Notifier
// @namespace zonure.scripts.tfn
// @version 1.0.0
// @description Chathead-style forum reply notifications. Drag to reposition, tap to view.
// @author Zonure
// @match https://www.torn.com/*
// @grant none
// @run-at document-idle
// @noframes
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/* === CONFIG === */
const ID = 'tfn';
const POLL_MS = 1 * 60 * 1000;
const HOLD_MS = 900;
const CH_W = 38;
const EDGE_PEEK = 5;
const NOTIF_CAP = 100;
const API_DELAY_MS = 650; // between paginated calls
const POST_DELAY_MS = 3 * 1000; // between per-thread post fetches
const THREAD_TTL_MS = 15 * 60 * 1000;
const PDA_KEY = '###PDA-APIKEY###';
const DEBUG = false;
/* === STORAGE KEYS === */
const K = {
key: `${ID}-apikey`,
threads: `${ID}-threads`, // { [id]: { id, title, forum_id, lastTotal, source } }
notifs: `${ID}-notifs`,
disabled: `${ID}-disabled`,
unread: `${ID}-unread`,
chY: `${ID}-ch-y`,
chSide: `${ID}-ch-side`,
threadsTs:`${ID}-threads-ts`,
inited: `${ID}-inited`,
lastPoll: `${ID}-last-poll`,
profile: `${ID}-profile`,
newTab: `${ID}-new-tab`,
};
/* === STATE === */
const S = {
apiKey: null,
threads: {},
notifs: [],
disabled: new Set(),
unread: 0,
polling: false,
pollTimer: null,
chathead: null,
dropOpen: false,
settingsOpen: false,
footerTicker: null,
callsThisMin: 0,
callMinuteBucket: null,
userId: null,
userName: null,
newTab: true,
};
/* === UTILS === */
const log = (...a) => DEBUG && console.log('[TFN]', ...a);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
const ls = {
get: k => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } },
set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { log('ls fail', k); } },
};
/* === API === */
const BASE = 'https://api.torn.com/v2';
async function fetchUrl(url) {
const sep = url.includes('?') ? '&' : '?';
const full = `${url}${sep}striptags=true&comment=%5BTornForumNotifier%5D&key=${S.apiKey}`;
const r = await fetch(full);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
if (d.error) throw new Error(d.error.error ?? 'API error');
const bucket = Math.floor(Date.now() / 60000);
if (bucket !== S.callMinuteBucket) { S.callMinuteBucket = bucket; S.callsThisMin = 0; }
S.callsThisMin++;
return d;
}
const apiFetch = path => fetchUrl(`${BASE}${path}`);
async function fetchAllPages(firstPath, dataKey) {
const items = [];
const d = await apiFetch(firstPath);
if (Array.isArray(d[dataKey])) items.push(...d[dataKey]);
let next = d._metadata?.links?.next ?? null;
while (next) {
await sleep(API_DELAY_MS);
const nd = await fetchUrl(next);
if (Array.isArray(nd[dataKey])) items.push(...nd[dataKey]);
next = nd._metadata?.links?.next ?? null;
}
return items;
}
/* === DATA PERSISTENCE === */
function loadState() {
// TornPDA injects key at install time
if (PDA_KEY.charAt(0) !== '#') {
S.apiKey = PDA_KEY;
ls.set(K.key, S.apiKey);
} else {
S.apiKey = ls.get(K.key);
}
S.threads = ls.get(K.threads) ?? {};
S.notifs = ls.get(K.notifs) ?? [];
S.unread = ls.get(K.unread) ?? 0;
S.disabled = new Set(ls.get(K.disabled) ?? []);
const prof = ls.get(K.profile);
S.userId = prof?.id ?? null;
S.userName = prof?.name ?? null;
S.newTab = ls.get(K.newTab) ?? true;
}
const saveThreads = () => ls.set(K.threads, S.threads);
const saveDisabled = () => ls.set(K.disabled, [...S.disabled]);
const saveUnread = () => ls.set(K.unread, S.unread);
const saveNotifs = () => {
if (S.notifs.length > NOTIF_CAP) S.notifs = S.notifs.slice(-NOTIF_CAP);
ls.set(K.notifs, S.notifs);
};
/* === USER PROFILE === */
async function fetchUserProfile() {
const d = await apiFetch('/user/basic');
const p = d.profile;
S.userId = p.id;
S.userName = p.name;
ls.set(K.profile, { id: p.id, name: p.name });
log('Profile loaded:', S.userName, S.userId);
}
/* === THREAD LIST REFRESH === */
async function refreshThreadList(toastFn) {
toastFn?.('⏳ Fetching threads… first run may be slow');
const own = await fetchAllPages('/user/forumthreads?limit=100', 'forumThreads');
await sleep(API_DELAY_MS);
const sub = await fetchAllPages('/user/forumsubscribedthreads', 'forumSubscribedThreads');
const activeIds = new Set([...own.map(t => t.id), ...sub.map(t => t.id)]);
for (const id of Object.keys(S.threads)) {
if (!activeIds.has(Number(id))) delete S.threads[id];
}
for (const t of own) {
const ex = S.threads[t.id];
S.threads[t.id] = {
id: t.id, title: t.title, forum_id: t.forum_id, source: 'own',
lastTotal: ex ? ex.lastTotal : t.posts,
};
}
// 'own' entries take precedence if a thread appears in both sources
for (const t of sub) {
const ex = S.threads[t.id];
if (!ex || ex.source === 'sub') {
S.threads[t.id] = {
id: t.id, title: t.title, forum_id: t.forum_id, source: 'sub',
lastTotal: ex ? ex.lastTotal : t.posts.total,
};
}
}
ls.set(K.inited, true);
ls.set(K.threadsTs, Date.now());
saveThreads();
log('Threads loaded:', Object.keys(S.threads).length);
}
/* === POLL === */
async function poll() {
if (S.polling || !S.apiKey) return;
S.polling = true;
ls.set(K.lastPoll, Date.now());
try {
const own = await fetchAllPages('/user/forumthreads?limit=100', 'forumThreads');
await sleep(API_DELAY_MS);
const sub = await fetchAllPages('/user/forumsubscribedthreads', 'forumSubscribedThreads');
const cur = {};
for (const t of sub) cur[t.id] = t.posts.total;
for (const t of own) cur[t.id] = t.posts;
const toFetch = [];
for (const [idStr, th] of Object.entries(S.threads)) {
const id = Number(idStr);
if (S.disabled.has(id)) continue;
const total = cur[id];
if (total !== undefined && total > th.lastTotal) {
toFetch.push({ th, offset: th.lastTotal, newCount: total - th.lastTotal, newTotal: total });
}
}
let newUnread = 0;
for (const item of toFetch) {
try {
await sleep(POST_DELAY_MS);
const fetchOffset = Math.max(item.offset, item.newTotal - 20);
const d = await apiFetch(`/forum/${item.th.id}/posts?offset=${fetchOffset}`);
const posts = d.posts ?? [];
// Skip self-posts
const otherPosts = S.userId
? posts.filter(p => p.author.id !== S.userId)
: posts;
const latest = otherPosts.length ? otherPosts[otherPosts.length - 1] : null;
if (latest) {
const existing = S.notifs.find(n => n.threadId === item.th.id);
if (existing) {
existing.author = latest.author.username;
existing.ts = latest.created_time * 1000;
existing.newCount += item.newCount;
// linkStart preserved so link points to where unread began
} else {
S.notifs.push({
threadId: item.th.id,
forumId: item.th.forum_id,
threadTitle: item.th.title,
author: latest.author.username,
linkStart: item.offset,
newCount: item.newCount,
ts: latest.created_time * 1000,
});
}
newUnread += item.newCount;
}
S.threads[item.th.id].lastTotal = item.newTotal;
} catch (e) {
log('Post fetch err, thread', item.th.id, e);
}
}
const fetchedIds = new Set(toFetch.map(f => f.th.id));
for (const [idStr] of Object.entries(S.threads)) {
const id = Number(idStr);
if (cur[id] !== undefined && !fetchedIds.has(id)) {
S.threads[id].lastTotal = cur[id];
}
}
saveThreads();
if (newUnread > 0) {
S.unread += newUnread;
saveUnread();
saveNotifs();
updateBadge();
rebuildDropIfOpen();
}
} catch (e) {
log('Poll error:', e);
} finally {
S.polling = false;
}
}
function startPolling() {
if (!S.apiKey || S.pollTimer) return;
const elapsed = Date.now() - (ls.get(K.lastPoll) ?? 0);
const waitMs = Math.max(0, POLL_MS - elapsed);
const kickoff = () => {
poll();
S.pollTimer = setInterval(poll, POLL_MS);
};
if (waitMs < 5000) {
kickoff();
} else {
log(`Next poll in ${Math.round(waitMs / 1000)}s`);
setTimeout(kickoff, waitMs);
}
}
/* === TOAST === */
function showToast(msg, ms = 4000) {
document.getElementById(`${ID}-toast`)?.remove();
const t = document.createElement('div');
t.id = `${ID}-toast`;
t.textContent = msg;
Object.assign(t.style, {
position: 'fixed', bottom: '80px', left: '50%',
transform: 'translateX(-50%)',
background: '#1a1c24', color: '#ccc',
border: '1px solid #2a2a2a', borderRadius: '4px',
padding: '8px 14px', fontSize: '12px',
zIndex: '100000', whiteSpace: 'nowrap',
boxShadow: '0 2px 12px rgba(0,0,0,0.6)',
pointerEvents: 'none',
});
document.body.appendChild(t);
setTimeout(() => t.remove(), ms);
}
/* === CHATHEAD === */
(() => {
const s = document.createElement('style');
s.textContent = `@keyframes ${ID}-pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(0,201,167,0.5); }
50% { box-shadow: 0 0 0 10px rgba(0,201,167,0); }
}`;
document.head.appendChild(s);
})();
const snapX = side =>
side === 'left' ? -EDGE_PEEK : window.innerWidth - CH_W + EDGE_PEEK;
function updateBadgeSide(side) {
const badge = document.getElementById(`${ID}-badge`);
if (!badge) return;
if (side === 'right') {
badge.style.left = '-5px'; badge.style.right = '';
} else {
badge.style.right = '-5px'; badge.style.left = '';
}
}
function buildChathead() {
if (document.getElementById(`${ID}-ch`)) return;
const ch = document.createElement('div');
ch.id = `${ID}-ch`;
const savedY = ls.get(K.chY) ?? 220;
const savedSide = ls.get(K.chSide) ?? 'right';
ch.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" width="17" height="17" style="pointer-events:none">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span id="${ID}-badge"></span>
`;
Object.assign(ch.style, {
position: 'fixed',
top: `${savedY}px`,
left: `${snapX(savedSide)}px`,
width: `${CH_W}px`,
height: `${CH_W}px`,
borderRadius:'50%',
background: '#0f1115',
border: '2px solid #00c9a7',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#00c9a7',
cursor: 'pointer',
zIndex: '99999',
opacity: '0.55',
transition: 'opacity 0.2s, box-shadow 0.2s',
userSelect: 'none',
touchAction: 'none',
boxShadow: '0 2px 10px rgba(0,0,0,0.55)',
});
const badge = ch.querySelector(`#${ID}-badge`);
Object.assign(badge.style, {
position: 'absolute', top: '-5px',
background: '#e05565', color: '#fff',
borderRadius: '10px', fontSize: '10px', fontWeight: '700',
minWidth: '16px', height: '16px', lineHeight: '16px',
textAlign: 'center', padding: '0 3px',
border: '1.5px solid #0f1115',
display: 'none', pointerEvents: 'none',
});
ch.addEventListener('mouseenter', () => {
if (dragState === 'idle') {
ch.style.opacity = '1';
ch.style.boxShadow = '0 2px 14px rgba(0,201,167,0.3)';
}
});
ch.addEventListener('mouseleave', () => {
if (dragState === 'idle' && !S.dropOpen) {
ch.style.opacity = '0.55';
ch.style.boxShadow = '0 2px 10px rgba(0,0,0,0.55)';
}
});
/* === TAP / HOLD-TO-DRAG === */
let dragState = 'idle'; // 'idle' | 'ready' | 'dragging'
let isTap = false, holdTimer = null;
let startCX = 0, startCY = 0;
let dragOffsetX = 0, dragOffsetY = 0;
let preDragSide = savedSide;
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
const getSide = () => ls.get(K.chSide) ?? 'right';
function enterDragReady() {
dragState = 'ready';
preDragSide = getSide();
const fullLeft = preDragSide === 'right' ? window.innerWidth - CH_W - 6 : 6;
ch.style.transition = 'left 0.2s ease';
ch.style.left = `${fullLeft}px`;
ch.style.opacity = '1';
ch.style.animation = `${ID}-pulse 0.9s ease infinite`;
setTimeout(() => { ch.style.transition = 'opacity 0.2s, box-shadow 0.2s'; }, 220);
}
function snapToEdge(releaseX) {
const side = releaseX < window.innerWidth / 2 ? 'left' : 'right';
ch.style.animation = '';
ch.style.transition = 'left 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
ch.style.left = `${snapX(side)}px`;
ls.set(K.chSide, side);
ls.set(K.chY, parseInt(ch.style.top));
updateBadgeSide(side);
setTimeout(() => {
ch.style.transition = 'opacity 0.2s, box-shadow 0.2s';
if (!S.dropOpen) ch.style.opacity = '0.55';
}, 300);
}
const onMove = e => {
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
if (dragState === 'idle') {
if (Math.hypot(cx - startCX, cy - startCY) > 12) {
isTap = false;
clearTimeout(holdTimer);
holdTimer = null;
}
return;
}
if (dragState === 'ready') {
if (Math.hypot(cx - startCX, cy - startCY) > 5) {
dragState = 'dragging';
ch.style.animation = '';
ch.style.transition = 'none';
// Track offset so chathead doesn't jump to cursor position
dragOffsetX = cx - parseFloat(ch.style.left);
dragOffsetY = cy - parseFloat(ch.style.top);
}
return;
}
if (dragState === 'dragging') {
ch.style.left = `${clamp(cx - dragOffsetX, 0, window.innerWidth - CH_W)}px`;
ch.style.top = `${clamp(cy - dragOffsetY, 10, window.innerHeight - CH_W - 8)}px`;
}
};
const onUp = e => {
clearTimeout(holdTimer);
holdTimer = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onUp);
if (dragState === 'idle') {
if (isTap) {
toggleDropdown();
} else {
ch.style.opacity = S.dropOpen ? '1' : '0.55';
}
} else if (dragState === 'ready') {
snapToEdge(preDragSide === 'right' ? window.innerWidth : 0);
} else if (dragState === 'dragging') {
const cx = e.changedTouches ? e.changedTouches[0].clientX : e.clientX;
snapToEdge(cx);
}
dragState = 'idle';
isTap = false;
};
ch.addEventListener('mousedown', e => {
if (dragState !== 'idle') return;
isTap = true;
startCX = e.clientX; startCY = e.clientY;
ch.style.opacity = '0.85';
holdTimer = setTimeout(enterDragReady, HOLD_MS);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
ch.addEventListener('touchstart', e => {
e.preventDefault();
if (dragState !== 'idle') return;
isTap = true;
startCX = e.touches[0].clientX; startCY = e.touches[0].clientY;
ch.style.opacity = '0.85';
holdTimer = setTimeout(enterDragReady, HOLD_MS);
document.addEventListener('touchmove', onMove, { passive: true });
document.addEventListener('touchend', onUp);
});
S.chathead = ch;
document.body.appendChild(ch);
updateBadgeSide(savedSide);
updateBadge();
}
function updateBadge() {
const badge = document.getElementById(`${ID}-badge`);
if (!badge) return;
badge.style.display = S.unread > 0 ? 'block' : 'none';
badge.textContent = S.unread > 99 ? '99+' : String(S.unread);
}
/* === DROPDOWN === */
function buildDropdown() {
const ch = S.chathead;
const side = ls.get(K.chSide) ?? 'right';
const rect = ch.getBoundingClientRect();
const W = 284;
const maxH = Math.min(420, window.innerHeight - 24);
let left = side === 'right' ? rect.left - W - 6 : rect.right + 6;
left = Math.max(4, Math.min(left, window.innerWidth - W - 4));
const top = Math.max(8, Math.min(rect.top - 8, window.innerHeight - maxH - 8));
const dd = document.createElement('div');
dd.id = `${ID}-dd`;
Object.assign(dd.style, {
position: 'fixed', top: `${top}px`, left: `${left}px`,
width: `${W}px`, maxHeight: `${maxH}px`,
background: 'var(--default-bg-panel-color, #141519)',
border: '1px solid var(--torn-border-color, #252525)',
borderRadius: '4px', zIndex: '99998',
display: 'flex', flexDirection: 'column',
boxShadow: '0 4px 20px rgba(0,0,0,0.65)',
fontFamily: 'inherit', fontSize: '13px',
color: 'var(--default-color, #ccc)',
overflow: 'hidden',
});
const hdr = document.createElement('div');
Object.assign(hdr.style, {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '8px 10px',
borderBottom: '1px solid var(--torn-border-color, #252525)',
flexShrink: '0',
});
hdr.innerHTML = `
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.2px;color:#00c9a7">Forum Notifications</span>
<div style="display:flex;gap:5px">
<button id="${ID}-markread" style="${btn()}">Mark Read</button>
<button id="${ID}-opensettings" style="${btn()}">⚙</button>
</div>
`;
const list = document.createElement('div');
Object.assign(list.style, {
overflowY: 'auto', flex: '1',
maxHeight: '340px',
});
const sorted = [...S.notifs].sort((a, b) => b.ts - a.ts);
if (!sorted.length) {
list.innerHTML = `<div style="padding:22px 12px;color:#555;text-align:center;font-size:12px">No notifications yet</div>`;
} else {
for (const n of sorted) {
const a = document.createElement('a');
a.href = `https://www.torn.com/forums.php#/p=threads&f=${n.forumId}&t=${n.threadId}&b=0&a=0&start=${n.linkStart}`;
a.target = S.newTab ? '_blank' : '_self';
Object.assign(a.style, {
display: 'block', padding: '8px 10px',
borderBottom: '1px solid rgba(255,255,255,0.04)',
textDecoration: 'none', color: 'inherit',
transition: 'background 0.1s',
});
a.addEventListener('mouseenter', () => a.style.background = 'rgba(0,201,167,0.06)');
a.addEventListener('mouseleave', () => a.style.background = '');
a.addEventListener('click', () => {
const idx = S.notifs.findIndex(x => x.threadId === n.threadId);
if (idx !== -1) S.notifs.splice(idx, 1);
S.unread = Math.max(0, S.unread - n.newCount);
saveNotifs();
saveUnread();
updateBadge();
if (S.newTab) closeDropdown();
});
const time = new Date(n.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const extra = n.newCount > 1 ? ` <span style="color:#666">(+${n.newCount})</span>` : '';
a.innerHTML = `
<div style="font-size:12px;color:#ccc;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
title="${esc(n.threadTitle)}">${esc(n.threadTitle)}</div>
<div style="font-size:11px;margin-top:2px;display:flex;justify-content:space-between;align-items:center">
<span style="color:#8fa898"><span style="color:#00c9a7">${esc(n.author)}</span> replied${extra}</span>
<span style="color:#3a3a3a;font-size:10px">${time}</span>
</div>
`;
list.appendChild(a);
}
}
dd.appendChild(hdr);
dd.appendChild(list);
// Footer — poll status
const ftr = document.createElement('div');
ftr.id = `${ID}-dd-footer`;
Object.assign(ftr.style, {
padding: '6px 10px',
borderTop: '1px solid var(--torn-border-color, #252525)',
flexShrink: '0',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
gap: '6px',
});
dd.appendChild(ftr);
function renderFooter() {
const lastPoll = ls.get(K.lastPoll);
const callBucket = Math.floor(Date.now() / 60000);
if (callBucket !== S.callMinuteBucket) { S.callMinuteBucket = callBucket; S.callsThisMin = 0; }
const callsPerMin = S.callsThisMin;
let updatedStr = '—';
let nextStr = '—';
if (lastPoll) {
const secAgo = Math.floor((Date.now() - lastPoll) / 1000);
updatedStr = secAgo < 60
? `${secAgo}s ago`
: `${Math.floor(secAgo / 60)}m ago`;
const secsUntil = Math.max(0, Math.ceil((lastPoll + POLL_MS - Date.now()) / 1000));
nextStr = secsUntil > 0
? (secsUntil >= 60 ? `${Math.ceil(secsUntil / 60)}m` : `${secsUntil}s`)
: 'now';
}
ftr.innerHTML = `
<span style="font-size:10px;color:#666;white-space:nowrap">
Updated <span style="color:#8fa898">${updatedStr}</span>
</span>
<span style="font-size:10px;color:#666;white-space:nowrap">
Next <span style="color:#8fa898">${nextStr}</span>
</span>
<span style="font-size:10px;color:#666;white-space:nowrap">
<span style="color:#8fa898">${callsPerMin}</span> calls/min
</span>
`;
}
renderFooter();
S.footerTicker = setInterval(() => {
if (document.getElementById(`${ID}-dd-footer`)) renderFooter();
else { clearInterval(S.footerTicker); S.footerTicker = null; }
}, 1000);
document.body.appendChild(dd);
dd.querySelector(`#${ID}-markread`).addEventListener('click', () => {
S.unread = 0; saveUnread(); updateBadge(); closeDropdown();
});
dd.querySelector(`#${ID}-opensettings`).addEventListener('click', () => {
closeDropdown(); openSettings();
});
}
function toggleDropdown() {
if (S.dropOpen) { closeDropdown(); return; }
S.dropOpen = true;
S.chathead.style.opacity = '1';
buildDropdown();
setTimeout(() => document.addEventListener('click', outsideClick), 50);
}
function closeDropdown() {
S.dropOpen = false;
document.getElementById(`${ID}-dd`)?.remove();
document.removeEventListener('click', outsideClick);
if (S.footerTicker) { clearInterval(S.footerTicker); S.footerTicker = null; }
if (S.chathead) {
S.chathead.style.opacity = '0.55';
S.chathead.style.boxShadow = '0 2px 10px rgba(0,0,0,0.55)';
}
}
function outsideClick(e) {
const dd = document.getElementById(`${ID}-dd`);
if (dd && !dd.contains(e.target) && !S.chathead.contains(e.target)) closeDropdown();
}
function rebuildDropIfOpen() {
if (!S.dropOpen) return;
document.getElementById(`${ID}-dd`)?.remove();
buildDropdown();
}
/* === SETTINGS === */
function openSettings() {
if (S.settingsOpen) { closeSettings(); return; }
S.settingsOpen = true;
const mobile = window.innerWidth <= 600;
const panel = document.createElement('div');
panel.id = `${ID}-settings`;
Object.assign(panel.style, {
position: 'fixed', zIndex: '99999',
width: '300px', maxHeight: '540px',
background: 'var(--default-bg-panel-color, #141519)',
border: '1px solid var(--torn-border-color, #252525)',
borderRadius: '4px',
boxShadow: '0 6px 24px rgba(0,0,0,0.75)',
fontFamily: 'inherit', fontSize: '13px',
color: 'var(--default-color, #ccc)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
...(mobile
? { top: '50%', left: '50%', transform: 'translate(-50%,-50%)' }
: { top: '80px', right: '56px' }),
});
panel.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-bottom:1px solid var(--torn-border-color,#252525);flex-shrink:0">
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.2px;color:#00c9a7">Torn Forum Notifier</span>
<button id="${ID}-s-close" style="${btn()}">✕</button>
</div>
<div style="padding:10px 12px;border-bottom:1px solid var(--torn-border-color,#252525);flex-shrink:0">
<div style="font-size:10px;color:#8fa898;text-transform:uppercase;letter-spacing:2px;margin-bottom:6px">API Key</div>
<div style="display:flex;gap:6px">
<input id="${ID}-s-key" type="password" placeholder="Paste API key here"
value="${esc(S.apiKey ?? '')}"
style="flex:1;min-width:0;background:#0d1017;border:1px solid #2a2a2a;color:#ccc;padding:5px 8px;border-radius:3px;font-size:12px;outline:none"/>
<button id="${ID}-s-keysave" style="${btn('#00c9a7','#0d1017')}">Save</button>
</div>
</div>
<div style="padding:8px 12px;border-bottom:1px solid var(--torn-border-color,#252525);flex-shrink:0;display:flex;align-items:center;justify-content:space-between">
<span style="font-size:12px;color:#ccc">Open links in new tab</span>
<input id="${ID}-s-newtab" type="checkbox" ${S.newTab ? 'checked' : ''}
style="accent-color:#00c9a7;cursor:pointer;width:14px;height:14px;flex-shrink:0"/>
</div>
<div style="padding:8px 12px;border-bottom:1px solid var(--torn-border-color,#252525);flex-shrink:0">
<div style="font-size:10px;color:#8fa898;text-transform:uppercase;letter-spacing:2px;margin-bottom:6px">Notify On Threads</div>
<input id="${ID}-s-search" type="text" placeholder="Search threads…"
style="width:100%;box-sizing:border-box;background:#0d1017;border:1px solid #2a2a2a;color:#ccc;padding:5px 8px;border-radius:3px;font-size:12px;outline:none;margin-bottom:6px"/>
<div style="display:flex;gap:5px">
<button id="${ID}-s-selall" style="${btn()}">Select All</button>
<button id="${ID}-s-deselall" style="${btn('#e05565')}">Deselect All</button>
</div>
</div>
<div id="${ID}-s-list" style="overflow-y:auto;flex:1;min-height:60px;max-height:220px"></div>
<div style="padding:8px 12px;border-top:1px solid var(--torn-border-color,#252525);flex-shrink:0;display:flex;gap:5px;justify-content:flex-end">
<button id="${ID}-s-clear" style="${btn('#e05565')}">Clear History</button>
<button id="${ID}-s-refresh" style="${btn()}">Refresh Threads</button>
</div>
`;
document.body.appendChild(panel);
renderThreadList('');
const search = () => panel.querySelector(`#${ID}-s-search`).value;
panel.querySelector(`#${ID}-s-close`).addEventListener('click', closeSettings);
panel.querySelector(`#${ID}-s-keysave`).addEventListener('click', doSaveKey);
panel.querySelector(`#${ID}-s-search`).addEventListener('input', e => renderThreadList(e.target.value));
panel.querySelector(`#${ID}-s-newtab`).addEventListener('change', e => {
S.newTab = e.target.checked;
ls.set(K.newTab, S.newTab);
});
panel.querySelector(`#${ID}-s-selall`).addEventListener('click', () => {
S.disabled.clear(); saveDisabled(); renderThreadList(search());
});
panel.querySelector(`#${ID}-s-deselall`).addEventListener('click', () => {
Object.keys(S.threads).forEach(id => S.disabled.add(Number(id)));
saveDisabled(); renderThreadList(search());
});
panel.querySelector(`#${ID}-s-clear`).addEventListener('click', () => {
S.notifs = []; S.unread = 0;
saveNotifs(); saveUnread(); updateBadge();
showToast('Notification history cleared');
});
panel.querySelector(`#${ID}-s-refresh`).addEventListener('click', async () => {
const b = panel.querySelector(`#${ID}-s-refresh`);
b.textContent = 'Fetching…'; b.disabled = true;
try {
await refreshThreadList(showToast);
renderThreadList(search());
showToast('✓ Thread list updated');
} catch (e) {
log('Refresh error:', e);
showToast('⚠ Refresh failed — check API key');
}
b.textContent = 'Refresh Threads'; b.disabled = false;
});
}
function closeSettings() {
document.getElementById(`${ID}-settings`)?.remove();
S.settingsOpen = false;
}
function renderThreadList(filter) {
const container = document.getElementById(`${ID}-s-list`);
if (!container) return;
container.innerHTML = '';
const all = Object.values(S.threads);
const shown = filter
? all.filter(t => t.title.toLowerCase().includes(filter.toLowerCase()))
: all;
if (!shown.length) {
container.innerHTML = `<div style="padding:16px 12px;color:#555;font-size:12px;text-align:center">
${filter ? 'No matching threads' : 'No threads found.<br>Enter your API key and tap Refresh Threads.'}
</div>`;
return;
}
for (const t of shown) {
const row = document.createElement('label');
Object.assign(row.style, {
display: 'flex', alignItems: 'center', gap: '8px',
padding: '6px 12px', cursor: 'pointer',
borderBottom: '1px solid rgba(255,255,255,0.03)',
transition: 'background 0.1s',
});
row.addEventListener('mouseenter', () => row.style.background = 'rgba(255,255,255,0.03)');
row.addEventListener('mouseleave', () => row.style.background = '');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !S.disabled.has(t.id);
cb.style.cssText = 'accent-color:#00c9a7;flex-shrink:0;cursor:pointer';
cb.addEventListener('change', () => {
cb.checked ? S.disabled.delete(t.id) : S.disabled.add(t.id);
saveDisabled();
});
const lbl = document.createElement('span');
lbl.title = t.title;
lbl.style.cssText = 'font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#ccc';
lbl.textContent = t.title;
const tag = document.createElement('span');
tag.style.cssText = `font-size:10px;padding:1px 5px;border-radius:2px;flex-shrink:0;font-weight:700;letter-spacing:0.5px;
${t.source === 'own'
? 'background:rgba(0,201,167,0.12);color:#00c9a7'
: 'background:rgba(143,168,152,0.1);color:#8fa898'}`;
tag.textContent = t.source === 'own' ? 'OWN' : 'SUB';
row.append(cb, lbl, tag);
container.appendChild(row);
}
}
function doSaveKey() {
const input = document.getElementById(`${ID}-s-key`);
const key = input?.value.trim();
if (!key) return;
S.apiKey = key;
ls.set(K.key, key);
S.userId = null; S.userName = null;
fetchUserProfile().catch(e => log('Profile fetch error on key save:', e));
const b = document.getElementById(`${ID}-s-keysave`);
if (b) { b.textContent = '✓ Saved'; setTimeout(() => b.textContent = 'Save', 1600); }
if (!Object.keys(S.threads).length) {
refreshThreadList(showToast)
.then(() => { renderThreadList(''); startPolling(); })
.catch(e => { log('Key save init error:', e); showToast('⚠ Failed to load threads'); });
} else {
startPolling();
}
}
/* === HELPERS === */
function btn(bg = '#1c1e26', color = '#ccc') {
const border = (bg === '#1c1e26') ? '#333' : bg;
return `background:${bg};color:${color};border:1px solid ${border};padding:3px 8px;border-radius:3px;font-size:11px;text-transform:uppercase;letter-spacing:0.4px;cursor:pointer;white-space:nowrap;flex-shrink:0`;
}
/* === INIT === */
function init() {
if (document.getElementById(`${ID}-ch`)) return;
loadState();
buildChathead();
if (!S.apiKey) {
openSettings();
return;
}
if (!S.userId) {
fetchUserProfile().catch(e => log('Profile fetch error:', e));
}
const ts = ls.get(K.threadsTs);
const stale = !ts || (Date.now() - ts > THREAD_TTL_MS);
if (stale || !Object.keys(S.threads).length) {
refreshThreadList(showToast)
.then(startPolling)
.catch(e => log('Init error:', e));
} else {
startPolling();
}
}
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', init)
: init();
})();