// ==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])
);
}
})();