// ==UserScript== // @name Soyjak Party thread watcher // @namespace soyjak-thread-watcher // @version 1.0.1 // @description thread watcher for soyjak.st // @match https://soyjak.st/* // @run-at document-end // @grant none // ==/UserScript== (() => { 'use strict'; /* -------------------- CONFIG -------------------- */ const STORE_KEY = 'sj_thread_watcher_v2'; const POS_KEY = 'sj_thread_watcher_pos'; const VISIBLE_KEY = 'sj_thread_watcher_visible'; const DEFAULT_POS = { x: 50, y: 50 }; const LABEL_MAX = 20; /* -------------------- EARLY EXITS -------------------- */ if ( location.pathname === '/' || location.pathname === '/index.php' || location.pathname === '/poll.php' || location.pathname === '/bans.php' || location.pathname === '/post.php' || location.pathname === '/pass.php' || location.pathname === '/banned.php' || location.pathname === '/rules.html' || location.pathname === '/b3.php' ) return; /* -------------------- STATE -------------------- */ const state = loadState(); const currentBoard = getBoardFromPath(); const currentThread = getThreadFromPath(); const currentKey = currentBoard && currentThread ? makeKey(currentBoard, currentThread) : null; /* -------------------- CSS -------------------- */ injectCSS(); /* -------------------- UI -------------------- */ const table = createWindow(); render(); updateVisibility(); /* -------------------- TOGGLE BUTTON -------------------- */ addToggleButton(); /* -------------------- INDEX: WATCH BUTTONS -------------------- */ addWatchButtons(); /* -------------------- THREAD PAGE READ TRACKING -------------------- */ if (currentKey) { markThreadRead(currentKey); hookPostClicks(currentKey); hookTinyboardInterop(currentKey); } /* ================================================================ FUNCTIONS ================================================================ */ function loadState() { try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; } catch { return {}; } } function saveState() { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } function makeKey(board, thread) { return `${board}.${thread}`; } function getBoardFromPath() { return location.pathname.split('/').filter(Boolean)[0] || null; } function getThreadFromPath() { const m = location.pathname.match(/\/thread\/(\d+)\.html$/); return m ? Number(m[1]) : null; } function isVisible() { const stored = localStorage.getItem(VISIBLE_KEY); return stored === null ? true : stored === 'true'; } function setVisible(visible) { localStorage.setItem(VISIBLE_KEY, visible.toString()); updateVisibility(); } function updateVisibility() { const visible = isVisible(); table.style.display = visible ? '' : 'none'; } function injectCSS() { const css = ` #twtable { position: fixed; background: #d6daf0; border: 1px solid #B7C5D9; border-left: 0; border-top: 0; font-family: Arial, Helvetica, sans-serif; font-size: 12px; max-width: 300px; z-index: 99999; } #twtable th { background: #9988EE; line-height: 12px; text-align: center; user-select: none; cursor: move; padding:0px; } #twtable th.refresh { width: 18px; cursor: default; } #twtable td { line-height: 14px; padding: 2px 4px; } #twtable a { text-decoration: none; } #twtable .count { font-weight: bold; } #twtable .remove { text-align: center; cursor: pointer; color: darkred; } .tw-watch-btn { margin-left: 6px; cursor: pointer; } .tw-toggle-btn { cursor: pointer; margin-right: 6px; } `; const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } function createWindow() { const pos = JSON.parse(localStorage.getItem(POS_KEY)) || DEFAULT_POS; const t = document.createElement('table'); t.id = 'twtable'; t.style.left = pos.x + 'px'; t.style.top = pos.y + 'px'; t.innerHTML = ` Thread Watcher `; document.body.appendChild(t); makeDraggable(t); t.querySelector('.refresh img').onclick = refreshAll; return t; } function makeDraggable(el) { let drag = false, ox = 0, oy = 0; el.querySelectorAll('th').forEach(th => { th.addEventListener('mousedown', e => { drag = true; ox = e.clientX - el.offsetLeft; oy = e.clientY - el.offsetTop; e.preventDefault(); }); }); document.addEventListener('mousemove', e => { if (!drag) return; el.style.left = (e.clientX - ox) + 'px'; el.style.top = (e.clientY - oy) + 'px'; }); document.addEventListener('mouseup', () => { if (!drag) return; drag = false; localStorage.setItem(POS_KEY, JSON.stringify({ x: el.offsetLeft, y: el.offsetTop })); }); } /* -------------------- TOGGLE BUTTON -------------------- */ function addToggleButton() { const boardlistSpan = document.querySelector('.boardlist .sub[data-description="0"]'); if (!boardlistSpan) { return; } const toggleLink = document.createElement('a'); toggleLink.textContent = 'thread watcher'; toggleLink.href = 'javascript:void(0)'; toggleLink.title = 'Toggle Thread Watcher'; toggleLink.onclick = (e) => { e.preventDefault(); setVisible(!isVisible()); }; // Add it after the closing bracket boardlistSpan.appendChild(document.createTextNode(' [')); boardlistSpan.appendChild(toggleLink); boardlistSpan.appendChild(document.createTextNode('] ')); } /* -------------------- WATCH LOGIC -------------------- */ async function watchThread(board, thread) { const key = makeKey(board, thread); if (state[key]) return; let json; try { json = await fetchJSON(board, thread); } catch {} const latest = getLatestNo(json) || thread; state[key] = { board, thread, lastSeen: latest, latest, unread: 0, label: extractLabel(json) || `/${board}/${thread}` }; saveState(); render(); } async function refreshAll() { for (const key in state) { const t = state[key]; // auto-read current thread if (key === currentKey) { t.unread = 0; continue; } try { const json = await fetchJSON(t.board, t.thread); const latest = getLatestNo(json); t.latest = latest; let unread = 0; for (const p of json.posts) { if (p.no > t.lastSeen) unread++; } t.unread = Math.max(0, unread); } catch {} } saveState(); render(); } function markThreadRead(key) { const t = state[key]; if (!t) return; t.lastSeen = t.latest; t.unread = 0; saveState(); } function hookPostClicks(key) { document.addEventListener('click', e => { if (!e.target.closest('.post')) return; markThreadRead(key); render(); }, true); } function hookTinyboardInterop(key) { document.addEventListener('new_post', () => { markThreadRead(key); render(); }); } /* -------------------- RENDER -------------------- */ function render() { const body = document.getElementById('twbody'); body.innerHTML = ''; const entries = Object.values(state); if (!entries.length) { body.innerHTML = ``; return; } entries.sort((a, b) => b.unread - a.unread); for (const t of entries) { const tr = document.createElement('tr'); const td1 = document.createElement('td'); const a = document.createElement('a'); a.href = `/` + t.board + `/thread/` + t.thread + `.html#` + t.latest; a.onclick = () => { t.lastSeen = t.latest; t.unread = 0; saveState(); }; a.innerHTML = t.unread ? `(${t.unread}) ${escapeHTML(t.label)}` : escapeHTML(t.label); td1.appendChild(a); const td2 = document.createElement('td'); td2.className = 'remove'; td2.textContent = 'x'; td2.onclick = () => { delete state[makeKey(t.board, t.thread)]; saveState(); render(); }; tr.append(td1, td2); body.appendChild(tr); } } /* -------------------- INDEX BUTTONS -------------------- */ function addWatchButtons() { document.querySelectorAll('.thread[id^="thread_"]').forEach(div => { if (div.querySelector('.tw-watch-btn')) return; const id = Number(div.id.replace('thread_', '')); const board = div.dataset.board; if (!board || !id) return; const btn = document.createElement('a'); btn.textContent = '[Watch Thread]'; btn.className = 'tw-watch-btn'; btn.onclick = () => watchThread(board, id); div.querySelector('.intro')?.appendChild(btn); }); } /* -------------------- JSON HELPERS -------------------- */ async function fetchJSON(board, thread) { const res = await fetch(`/${board}/thread/${thread}.json`); if (!res.ok) throw 0; return res.json(); } function getLatestNo(json) { if (!json?.posts) return null; return Math.max(...json.posts.map(p => p.no)); } function extractLabel(json) { const op = json?.posts?.find(p => !p.resto); if (!op?.___body_nomarkup) return null; let text = op.___body_nomarkup .replace(/^[=>#]+/gm, '') .replace(/\s+/g, ' ') .trim(); if (text.length > LABEL_MAX) text = text.slice(0, LABEL_MAX - 1) + '…'; return text || null; } function escapeHTML(s) { return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]) ); } })();