// ==UserScript== // @name tw // @namespace soyjak-thread-watcher // @version 1 // @description thread watcher for soyjak.st // @match https://soyjak.st/* // @run-at document-end // @grant none // ==/UserScript== (() => { 'use strict'; const STORE_KEY = 'threadwatcher'; const POS_KEY = 'threadwatcher_pos'; const VISIBLE_KEY = 'threadwatcher_hidden'; const DEFAULT_POS = { x: 50, y: 50 }; const LABEL_MAX = 30; if (!$('body').hasClass('vichan')) { return; } const state = loadState(); const currentBoard = getBoardFromPath(); const currentThread = getThreadFromPath(); const currentKey = currentBoard && currentThread ? makeKey(currentBoard, currentThread) : null; injectCSS(); const table = createWindow(); render(); updateVisibility(); addToggleButton(); addWatchButtons(); if (currentKey) { hookPostClicks(currentKey); hookTinyboardInterop(currentKey); hookAutoReload(currentKey); } 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:absolute;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:29;border-spacing:0px}#twtable th{line-height:0;text-align:center;user-select:none;cursor:move;padding:0;border-bottom:1px solid #d0c8c8}#twtable th.refresh{width:18px;cursor:default}#twtable td{line-height:14px;padding:2px 4px}#twtable .remove{text-align:center;cursor:pointer;color:#989898}.tw-watch-btn{margin-left:6px;cursor:pointer}.tw-toggle-btn{cursor:pointer;margin-right:6px}#twtable a{text-decoration:none}#twtable .count{font-weight:700}#twtable .remove:hover{color:#8b0000}`; const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } function createWindow() { let pos = JSON.parse(localStorage.getItem(POS_KEY)) || DEFAULT_POS; if (pos.x > 100 || pos.y > 100) { pos = { x: (pos.x / window.innerWidth) * 100, y: (pos.y / window.innerHeight) * 100 }; localStorage.setItem(POS_KEY, JSON.stringify(pos)); } const t = document.createElement('table'); t.id = 'twtable'; t.style.left = pos.x + '%'; t.style.top = pos.y + '%'; 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; const rect = el.getBoundingClientRect(); ox = e.clientX - rect.left; oy = e.clientY - rect.top; e.preventDefault(); }); }); document.addEventListener('mousemove', e => { if (!drag) return; const vw = window.innerWidth; const vh = window.innerHeight; let leftPx = e.clientX - ox; let topPx = e.clientY - oy; let leftPct = (leftPx / vw) * 100; let topPct = (topPx / vh) * 100; leftPct = Math.max(0, Math.min(100, leftPct)); topPct = Math.max(0, Math.min(100, topPct)); el.style.left = leftPct + '%'; el.style.top = topPct + '%'; }); document.addEventListener('mouseup', () => { if (!drag) return; drag = false; const rect = el.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; const leftPct = (rect.left / vw) * 100; const topPct = (rect.top / vh) * 100; localStorage.setItem(POS_KEY, JSON.stringify({ x: leftPct, y: topPct })); }); } function addToggleButton() { const boardlistSpan = document.querySelector('.boardlist .sub[data-description="0"]'); if (!boardlistSpan) return; const toggleLink = document.createElement('a'); toggleLink.textContent = 'tw'; toggleLink.href = 'javascript:void(0)'; toggleLink.title = 'Toggle Thread Watcher'; toggleLink.onclick = (e) => { e.preventDefault(); setVisible(!isVisible()); }; boardlistSpan.appendChild(document.createTextNode(' [ ')); boardlistSpan.appendChild(toggleLink); boardlistSpan.appendChild(document.createTextNode(' ] ')); } 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}`, order: Object.keys(state).length }; saveState(); render(); } async function refreshAll() { const refreshImg = table.querySelector('.refresh img'); refreshImg.src = 'https://soyjak.st/x/src/1767066253133r.gif'; for (const key in state) { const t = state[key]; if (key === currentKey) { const posts = document.querySelectorAll('.post.reply'); if (posts.length > 0) { const lastPost = posts[posts.length - 1]; const postNo = parseInt(lastPost.id.replace('reply_', '')); t.latest = Math.max(t.latest, postNo); t.lastSeen = t.latest; } 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(); refreshImg.src = 'https://soyjak.st/x/src/1767059688067u-1.png'; } function markThreadRead(key) { const t = state[key]; if (!t) return; const posts = document.querySelectorAll('.post.reply'); if (posts.length > 0) { const lastPost = posts[posts.length - 1]; const postNo = parseInt(lastPost.id.replace('reply_', '')); t.latest = Math.max(t.latest, postNo); } 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', () => { const t = state[key]; if (!t) return; const posts = document.querySelectorAll('.post.reply'); if (posts.length > 0) { const lastPost = posts[posts.length - 1]; const postNo = parseInt(lastPost.id.replace('reply_', '')); if (postNo > t.latest) { t.latest = postNo; } } markThreadRead(key); render(); }); } function hookAutoReload(key) { const updateThread = document.getElementById('update_thread'); if (updateThread) { const observer = new MutationObserver(() => { const t = state[key]; if (!t) return; const posts = document.querySelectorAll('.post.reply'); if (posts.length > 0) { const lastPost = posts[posts.length - 1]; const postNo = parseInt(lastPost.id.replace('reply_', '')); if (postNo > t.latest) { t.latest = postNo; saveState(); render(); } } }); const form = document.querySelector('form[name="postcontrols"]'); if (form) { observer.observe(form, { childList: true, subtree: true }); } } } function render() { const body = document.getElementById('twbody'); body.innerHTML = ''; const entries = Object.values(state); if (!entries.length) { body.innerHTML = ``; return; } entries.forEach((t, i) => { if (t.order === undefined) { t.order = i; } }); entries.sort((a, b) => a.order - b.order); 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(formatLabel(t.board, t.label))}` : escapeHTML(formatLabel(t.board, 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); } } 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); }); } 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 formatLabel(board, label) { const prefix = `/${board}/ - `; const maxContent = LABEL_MAX - prefix.length; if (label && label.match(/^\/\w+\/ - /)) { return label; } if (!label || label.match(/^\/\w+\/\d+$/)) { return label || `/${board}/`; } if (label.length > maxContent) { return prefix + label.slice(0, maxContent - 1) + '…'; } return prefix + label; } function escapeHTML(s) { return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } })();