// ==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 =
'' +
'' +
group('Names', 'name', 'add name') +
group('Tripcodes', 'trip', 'add tripcode') +
group('Colors', 'color', 'add #hex') +
'
';
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 (
'' +
'
' +
'
' +
'
' +
'
' +
'
'
);
}
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();
})();