// ==UserScript== // @name Heyuri Comchat Filter (names / tripcodes / colors) // @namespace heyuri.comchat.filter // @version 1.1 // @description Block chat messages by name, tripcode, or color from a plain top-bar menu. // @match https://cgi.heyuri.net/chat/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addValueChangeListener // @run-at document-idle // ==/UserScript== /* Runs inside the message-log frame. Scans every .messageRow, pulls out the name / tripcode / color, and hides any row (and its
separator) whose name, tripcode, or color you've chosen to block. A plain "Filters" button is added to the top-left. Click it for a menu with three lists (Names, Tripcodes, Colors). Tick a box to block, untick to unblock. Choices persist across the 15s auto-refresh and browser restarts. */ (function () { 'use strict'; // ---- persistent state -------------------------------------------------- const KEY = 'heyuri_filter_v1'; const defaults = { names: [], trips: [], colors: [], seenNames: [], seenTrips: [], seenColors: [], open: false }; let state = Object.assign({}, defaults, GM_getValue(KEY, {})); const save = () => GM_setValue(KEY, state); let nameBlock, tripBlock, colorBlock; function rebuildSets() { nameBlock = new Set(state.names.map(s => s.toLowerCase())); tripBlock = new Set(state.trips); // tripcodes: case-sensitive colorBlock = new Set(state.colors.map(s => s.toLowerCase())); } rebuildSets(); // ---- helpers ----------------------------------------------------------- const _cx = document.createElement('canvas').getContext('2d'); function toHex(color) { if (!color) return ''; try { _cx.fillStyle = '#000'; _cx.fillStyle = color; return _cx.fillStyle.toLowerCase(); } catch (e) { return String(color).toLowerCase(); } } function seenAdd(list, val) { if (!val) return; if (!list.includes(val)) { list.push(val); if (list.length > 200) list.shift(); } } // The
that belongs to a row (the one right before it, matching the // page's "separator, message, separator, message" layout). function separatorFor(row) { const p = row.previousElementSibling; if (p && p.tagName === 'HR') return p; const n = row.nextElementSibling; if (n && n.tagName === 'HR') return n; return null; } function parseRow(row) { const nameEl = row.querySelector('.messageName'); if (!nameEl) return null; const full = (nameEl.textContent || '').trim(); const bold = nameEl.querySelector('b'); let di = full.indexOf('\u25C6'); // ◆ if (di === -1) di = full.indexOf('\u2666'); // ♦ const trip = di !== -1 ? full.slice(di + 1).trim() : ''; let name = bold ? bold.textContent.trim() : ''; if (!name) { const base = di !== -1 ? full.slice(0, di) : full; name = base.replace(/^[\u2605\u2606\u25C7\u25C6\u2666\s]+/, '').trim(); } const color = toHex(getComputedStyle(nameEl).color); return { name, trip, color }; } function applyRow(row) { const info = parseRow(row); if (!info) return; seenAdd(state.seenNames, info.name); if (info.trip) seenAdd(state.seenTrips, info.trip); seenAdd(state.seenColors, info.color); const hide = (info.name && nameBlock.has(info.name.toLowerCase())) || (info.trip && tripBlock.has(info.trip)) || (info.color && colorBlock.has(info.color.toLowerCase())); row.style.display = hide ? 'none' : ''; const sep = separatorFor(row); if (sep) sep.style.display = hide ? 'none' : ''; } function applyAll() { document.querySelectorAll('.messageRow').forEach(applyRow); } function scanAll() { if (!document.querySelector('.messageRow')) return; // not the log frame applyAll(); save(); buildMenuIfNeeded(); refreshLists(); } // ---- block / unblock --------------------------------------------------- function setBlocked(kind, value, on) { const arr = kind === 'name' ? state.names : kind === 'trip' ? state.trips : state.colors; const eq = kind === 'trip' ? x => x === value : x => x.toLowerCase() === value.toLowerCase(); const idx = arr.findIndex(eq); if (on && idx === -1) arr.push(value); if (!on && idx !== -1) arr.splice(idx, 1); rebuildSets(); save(); applyAll(); refreshLists(); } // ---- menu UI (plain / native styling) ---------------------------------- let menuBuilt = false, panel, toggleBtn; // Minimal CSS: inherit the page's font, use the browser's own system colors // and default form controls so it blends in instead of looking like a widget. const CSS = ` #hf-bar{position:fixed;top:4px;left:4px;z-index:2147483647;font:inherit} #hf-toggle{font:inherit;cursor:pointer} #hf-panel{margin-top:3px;width:225px;max-height:72vh;overflow:auto; background:Canvas;color:CanvasText;border:1px solid ButtonBorder;padding:5px;font:inherit} #hf-panel[hidden]{display:none} .hf-group{margin-bottom:8px} .hf-head{font-weight:bold;display:flex;justify-content:space-between;align-items:baseline} .hf-head a{font-weight:normal;font-size:smaller} .hf-add{display:flex;gap:3px;margin:3px 0} .hf-add input{flex:1;min-width:0;font:inherit} .hf-add button{font:inherit;cursor:pointer} .hf-list{display:flex;flex-direction:column} .hf-item{display:flex;align-items:center;gap:5px;cursor:pointer} .hf-swatch{display:inline-block;width:11px;height:11px;border:1px solid ButtonBorder} .hf-val{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .hf-empty{color:GrayText} `; function buildMenuIfNeeded() { if (menuBuilt || !document.body) return; menuBuilt = true; const style = document.createElement('style'); style.textContent = CSS; document.head.appendChild(style); const bar = document.createElement('div'); bar.id = 'hf-bar'; bar.innerHTML = '' + ''; document.body.appendChild(bar); panel = bar.querySelector('#hf-panel'); toggleBtn = bar.querySelector('#hf-toggle'); panel.hidden = !state.open; toggleBtn.addEventListener('click', () => { state.open = panel.hidden; panel.hidden = !state.open; save(); }); panel.addEventListener('change', e => { const cb = e.target.closest('input[type=checkbox]'); if (cb) setBlocked(cb.dataset.kind, cb.dataset.value, cb.checked); }); panel.addEventListener('click', e => { const add = e.target.closest('.hf-add button'); if (add) { const inp = add.previousElementSibling; const raw = inp.value.trim(); if (raw) { const kind = add.dataset.kind; setBlocked(kind, kind === 'color' ? (toHex(raw) || raw) : raw, true); inp.value = ''; } return; } const clear = e.target.closest('.hf-clear'); if (clear) { e.preventDefault(); const kind = clear.dataset.kind; (kind === 'name' ? state.names : kind === 'trip' ? state.trips : state.colors).length = 0; rebuildSets(); save(); applyAll(); refreshLists(); } }); panel.addEventListener('keydown', e => { if (e.key === 'Enter' && e.target.matches('.hf-add input')) { e.preventDefault(); e.target.nextElementSibling.click(); } }); refreshLists(); } function group(title, kind, ph) { return ( '
' + '
' + title + ' clear
' + '
' + '
' + '
' + '
' ); } const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); function refreshLists() { if (!menuBuilt) return; const n = state.names.length + state.trips.length + state.colors.length; toggleBtn.textContent = n ? 'Filters (' + n + ')' : 'Filters'; renderList('name', union(state.seenNames, state.names).sort(sortCI), state.names, false); renderList('trip', union(state.seenTrips, state.trips).sort(sortCI), state.trips, false); renderList('color', union(state.seenColors, state.colors).sort(), state.colors, true); } function union(a, b) { const out = a.slice(); b.forEach(v => { if (!out.some(x => x.toLowerCase() === v.toLowerCase())) out.push(v); }); return out; } const sortCI = (a, b) => a.toLowerCase().localeCompare(b.toLowerCase()); function renderList(kind, values, blockedArr, isColor) { const list = panel.querySelector('.hf-list[data-kind="' + kind + '"]'); const blockedSet = new Set(blockedArr.map(x => kind === 'trip' ? x : x.toLowerCase())); if (!values.length) { list.innerHTML = 'nothing yet'; return; } list.innerHTML = values.map(v => { const checked = blockedSet.has(kind === 'trip' ? v : v.toLowerCase()) ? ' checked' : ''; const swatch = isColor ? '' : ''; return ''; }).join(''); } // ---- sync across other tabs/frames ------------------------------------- if (typeof GM_addValueChangeListener === 'function') { GM_addValueChangeListener(KEY, (name, oldV, newV, remote) => { if (!remote) return; state = Object.assign({}, defaults, newV); rebuildSets(); applyAll(); refreshLists(); if (panel) panel.hidden = !state.open; }); } // ---- boot -------------------------------------------------------------- function init() { if (!document.body) { requestAnimationFrame(init); return; } scanAll(); const mo = new MutationObserver(muts => { for (const m of muts) for (const nd of m.addedNodes) { if (nd.nodeType === 1 && (nd.classList?.contains('messageRow') || nd.querySelector?.('.messageRow'))) { scanAll(); return; } } }); mo.observe(document.body, { childList: true, subtree: true }); } init(); })();