// ==UserScript==
// @name BerryTube Mention Threads
// @namespace btcustomload
// @version 0.2.1
// @homepageURL https://github.com/flames5123/BT-mentions-threads
// @supportURL https://github.com/flames5123/BT-mentions-threads/issues
// @updateURL https://raw.githubusercontent.com/flames5123/BT-mentions-threads/main/MentionThreads.user.js
// @downloadURL https://raw.githubusercontent.com/flames5123/BT-mentions-threads/main/MentionThreads.user.js
// @description Click any username (a chat @mention OR a message's sender name) to thread it: jump to that person's relevant message and step older/newer with a pinned header bar. Works in both vanilla BerryTube chat and the MalTweaks tweaked view. Uses wutColors when enabled.
// @author (by request)
// @match http://berrytube.tv/*
// @match http://www.berrytube.tv/*
// @match https://berrytube.tv/*
// @match https://www.berrytube.tv/*
// @match https://berrytube.berrypun.ch:8445/*
// @match http://tunnel.berrypun.ch/*
// @match http://tunnel.q-z.xyz/*
// @match http://btc.berrytube.tv:8000/*
// @match https://new.berrytube.tv:8443/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
// ---------------------------------------------------------------------------
// Known-username registry (from #chatlist + every msgwrap[nick]). Only FULL
// usernames from this set are ever linkified, so client-side squee aliases
// are never touched.
// ---------------------------------------------------------------------------
var names = new Set();
var lc2canon = {};
var mentionRegex = null;
var regexDirty = true;
function registerName(n) {
if (!n) return;
var key = n.toLowerCase();
if (!(key in lc2canon)) { names.add(n); lc2canon[key] = n; regexDirty = true; }
}
function buildRegex() {
if (!regexDirty) return;
regexDirty = false;
var arr = Array.from(names);
if (!arr.length) { mentionRegex = null; return; }
arr.sort(function (a, b) { return b.length - a.length; }); // longest first
var esc = arr.map(function (n) { return n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); });
try {
mentionRegex = new RegExp('(?= 3px == "wutColors on".
// ---------------------------------------------------------------------------
var colorCache = {};
function firstWithNick(selector, nick) {
var els = document.querySelectorAll(selector);
for (var i = 0; i < els.length; i++) if (els[i].getAttribute('nick') === nick) return els[i];
return null;
}
function computeColor(nick) {
var candidates = [firstWithNick('.msgwrap', nick), firstWithNick('#chatlist [nick]', nick)];
for (var i = 0; i < candidates.length; i++) {
var el = candidates[i];
if (!el) continue;
var cs = getComputedStyle(el);
if (parseFloat(cs.borderLeftWidth) >= 3) {
var c = cs.borderLeftColor;
if (c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent') return c;
}
}
return null;
}
function getUserColor(nick) {
if (nick in colorCache) return colorCache[nick];
var c = computeColor(nick);
if (c) colorCache[nick] = c; // only cache real (enabled) colors
return c;
}
// ---------------------------------------------------------------------------
// Styling.
// ---------------------------------------------------------------------------
function injectStyle() {
if (document.getElementById('mtn-style')) return;
var css = [
'.mtn-mention{cursor:pointer;display:inline-block;padding:0 4px;border:1px solid rgba(120,180,255,.55);',
' border-radius:4px;background:rgba(255,255,255,.06);line-height:1.35;}',
'.mtn-mention:hover{background:rgba(255,255,255,.16);}',
'.nick.mtn-nick-link{cursor:pointer;}',
'.nick.mtn-nick-link:hover{text-decoration:underline dotted;}',
'.mtn-bar{position:fixed;z-index:2147483000;display:none;align-items:center;gap:6px;',
' font-size:12px;line-height:1;padding:5px 8px;box-sizing:border-box;',
' background:rgba(20,28,40,.94);color:#e7eefc;border-bottom:1px solid rgba(120,180,255,.5);',
' box-shadow:0 2px 6px rgba(0,0,0,.4);}',
'.mtn-bar .mtn-thread-icon{opacity:.85;}',
'.mtn-bar .mtn-name{font-weight:bold;color:#8fc0ff;max-width:40%;overflow:hidden;',
' text-overflow:ellipsis;white-space:nowrap;}',
'.mtn-bar .mtn-count{opacity:.7;margin-left:2px;}',
'.mtn-bar .mtn-empty{opacity:.7;font-style:italic;display:none;}',
'.mtn-bar .mtn-spacer{flex:1 1 auto;}',
'.mtn-bar .mtn-btn{cursor:pointer;border:1px solid rgba(120,180,255,.5);background:rgba(120,180,255,.15);',
' color:#e7eefc;border-radius:4px;padding:2px 8px;font-size:12px;line-height:1;min-width:26px;}',
'.mtn-bar .mtn-btn:hover:not([disabled]){background:rgba(120,180,255,.35);}',
'.mtn-bar .mtn-btn[disabled]{opacity:.35;cursor:default;}',
'.msgwrap.mtn-focus{outline:2px solid rgba(120,180,255,.9);outline-offset:-1px;border-radius:3px;',
' animation:mtnFlash 1.1s ease-out 1;}',
'@keyframes mtnFlash{0%{background:rgba(120,180,255,.30);}100%{background:transparent;}}'
].join('');
var s = document.createElement('style');
s.id = 'mtn-style';
s.textContent = css;
(document.head || document.documentElement).appendChild(s);
}
// ---------------------------------------------------------------------------
// Linkify a .msg element's text nodes.
// ---------------------------------------------------------------------------
function linkifyMsg(msgEl) {
buildRegex();
if (!mentionRegex) return;
var walker = document.createTreeWalker(msgEl, NodeFilter.SHOW_TEXT, {
acceptNode: function (n) {
if (!n.nodeValue || !n.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
var p = n.parentNode;
while (p && p !== msgEl) {
var tag = p.tagName;
if (tag === 'A' || tag === 'CODE' || tag === 'IMG') return NodeFilter.FILTER_REJECT;
if (p.classList && (p.classList.contains('mtn-mention') || p.classList.contains('berrymote')))
return NodeFilter.FILTER_REJECT;
p = p.parentNode;
}
return NodeFilter.FILTER_ACCEPT;
}
});
var textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
textNodes.forEach(function (node) {
var text = node.nodeValue;
mentionRegex.lastIndex = 0;
var m, last = 0, frag = null;
while ((m = mentionRegex.exec(text))) {
var matchText, matchIndex;
if (mentionRegex._noLookbehind) { matchText = m[2]; matchIndex = m.index + m[1].length; }
else { matchText = m[0]; matchIndex = m.index; }
if (!frag) frag = document.createDocumentFragment();
if (matchIndex > last) frag.appendChild(document.createTextNode(text.slice(last, matchIndex)));
var canonical = lc2canon[matchText.toLowerCase()] || matchText;
var span = document.createElement('span');
span.className = 'mtn-mention';
span.setAttribute('data-nick', canonical);
span.textContent = matchText;
var col = getUserColor(canonical);
if (col) { span.style.color = col; span.style.borderColor = col; }
frag.appendChild(span);
last = matchIndex + matchText.length;
if (mentionRegex._noLookbehind) mentionRegex.lastIndex = last;
}
if (frag) {
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
node.parentNode.replaceChild(frag, node);
}
});
}
function processMsgwrap(wrap) {
if (!wrap || wrap._mtnDone) return;
wrap._mtnDone = true;
var nick = wrap.getAttribute('nick');
registerName(nick);
var label = wrap.querySelector('.message > .nick, .nick');
if (label && nick) { label.classList.add('mtn-nick-link'); label.setAttribute('data-nick', nick); }
var msg = wrap.querySelector('.msg');
if (msg) linkifyMsg(msg);
}
// ---------------------------------------------------------------------------
// Single fixed thread bar that mirrors the active buffer's rectangle.
// ---------------------------------------------------------------------------
var bar = null, activeBuffer = null, activeNick = null, activeIdx = -1, activeColor = null, posTimer = null, ro = null;
function buildBar() {
if (bar) return bar;
injectStyle();
bar = document.createElement('div');
bar.className = 'mtn-bar';
bar.innerHTML =
'\uD83E\uDDF5' +
'' +
'' +
'no messages in view' +
'' +
'' +
'' +
'';
document.body.appendChild(bar);
bar.querySelector('.mtn-up').addEventListener('click', function () { step(-1); });
bar.querySelector('.mtn-down').addEventListener('click', function () { step(1); });
bar.querySelector('.mtn-close').addEventListener('click', closeBar);
return bar;
}
function positionBar() {
if (!bar || !activeBuffer || !activeNick) return;
if (!activeBuffer.isConnected || !activeBuffer.offsetParent) { bar.style.display = 'none'; return; }
var r = activeBuffer.getBoundingClientRect();
if (r.width === 0 || r.height === 0) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
bar.style.left = r.left + 'px';
bar.style.top = r.top + 'px';
bar.style.width = r.width + 'px';
// Push the buffer's first line clear of the bar.
activeBuffer.style.paddingTop = bar.offsetHeight + 'px';
}
function messagesFor(buffer, nick) {
return Array.prototype.filter.call(buffer.querySelectorAll('.msgwrap'),
function (w) { return w.getAttribute('nick') === nick; });
}
// Initial index rule:
// - mention click (anchor is someone else's message): most recent target
// message strictly ABOVE the anchor. ▼ can still reach later ones.
// - name-label click (anchor is the target's own message): that exact one.
// - nothing at/above the anchor: earliest, so ▼ can reach the rest.
function indexForAnchor(msgs, anchor) {
if (!anchor) return msgs.length - 1;
var idx = -1;
for (var i = 0; i < msgs.length; i++) {
if (msgs[i] === anchor) { idx = i; break; }
var pos = anchor.compareDocumentPosition(msgs[i]);
if (pos & Node.DOCUMENT_POSITION_PRECEDING) idx = i;
else if (pos & Node.DOCUMENT_POSITION_FOLLOWING) break;
}
return idx < 0 ? (msgs.length ? 0 : -1) : idx;
}
// Visible region of the buffer BELOW our pinned bar.
function bufferVisibleBox(buffer) {
var br = buffer.getBoundingClientRect();
var barH = (bar && bar.style.display !== 'none') ? bar.offsetHeight : 0;
return { top: br.top + barH, bottom: br.bottom, height: br.height - barH };
}
// Drive the buffer's own scrollTop (reliable across vanilla/MalTweaks layouts;
// scrollIntoView can scroll the wrong ancestor / the page).
function scrollToTarget(buffer, target) {
var vb = bufferVisibleBox(buffer);
var tr = target.getBoundingClientRect();
var delta = (tr.top - vb.top) - Math.max(0, (vb.height - tr.height) / 2);
buffer.scrollTop += delta;
}
function targetVisible(buffer, target) {
var vb = bufferVisibleBox(buffer);
var tr = target.getBoundingClientRect();
return tr.top >= vb.top - 2 && tr.bottom <= vb.bottom + 2;
}
// BerryTube's scrollBuffersToBottom() force-scrolls the buffer to the bottom on
// EVERY incoming message. While a thread is open we re-pin the focused message
// so that auto-scroll can't yank it out of view.
function keepFocusPinned() {
if (!activeBuffer || !activeNick) return;
var msgs = messagesFor(activeBuffer, activeNick);
if (activeIdx < 0 || activeIdx >= msgs.length) return;
var target = msgs[activeIdx];
if (!target) return;
requestAnimationFrame(function () {
if (activeBuffer && target.isConnected && !targetVisible(activeBuffer, target))
scrollToTarget(activeBuffer, target);
});
}
function clearFocus(buffer) {
buffer.querySelectorAll('.msgwrap.mtn-focus').forEach(function (el) {
el.classList.remove('mtn-focus');
el.style.outlineColor = '';
});
}
function render() {
if (!activeBuffer) return;
var msgs = messagesFor(activeBuffer, activeNick);
var n = msgs.length;
if (activeIdx >= n) activeIdx = n - 1;
if (activeIdx < 0 && n) activeIdx = 0;
var nameEl = bar.querySelector('.mtn-name');
nameEl.textContent = activeNick;
nameEl.style.color = activeColor || '';
var count = bar.querySelector('.mtn-count');
var empty = bar.querySelector('.mtn-empty');
var up = bar.querySelector('.mtn-up');
var down = bar.querySelector('.mtn-down');
clearFocus(activeBuffer);
if (n === 0) {
count.style.display = 'none';
empty.style.display = '';
up.disabled = down.disabled = true;
positionBar();
return;
}
empty.style.display = 'none';
count.style.display = '';
count.textContent = '(' + (activeIdx + 1) + '/' + n + ')';
up.disabled = (activeIdx <= 0);
down.disabled = (activeIdx >= n - 1);
var target = msgs[activeIdx];
target.classList.add('mtn-focus');
if (activeColor) target.style.outlineColor = activeColor;
positionBar();
scrollToTarget(activeBuffer, target);
}
function step(delta) { activeIdx += delta; render(); }
function focusThread(nick, buffer, anchor) {
buildBar();
activeBuffer = buffer;
activeNick = nick;
activeColor = getUserColor(nick);
var msgs = messagesFor(buffer, nick);
activeIdx = indexForAnchor(msgs, anchor);
// Track the buffer's geometry (pane resize, users-panel toggle, tab switch).
if (ro) { ro.disconnect(); ro = null; }
if (window.ResizeObserver) { ro = new ResizeObserver(positionBar); ro.observe(buffer); }
if (posTimer) clearInterval(posTimer);
posTimer = setInterval(positionBar, 250);
render();
}
function closeBar() {
if (bar) bar.style.display = 'none';
if (activeBuffer) { clearFocus(activeBuffer); activeBuffer.style.paddingTop = ''; }
if (ro) { ro.disconnect(); ro = null; }
if (posTimer) { clearInterval(posTimer); posTimer = null; }
activeBuffer = activeNick = null;
activeIdx = -1;
}
window.addEventListener('resize', positionBar);
// ---------------------------------------------------------------------------
// Delegated click.
// ---------------------------------------------------------------------------
document.addEventListener('click', function (e) {
var t = e.target.closest && e.target.closest('.mtn-mention, .nick.mtn-nick-link');
if (!t) return;
var nick = t.getAttribute('data-nick');
if (!nick) return;
var buffer = (t.closest && t.closest('.chatbuffer')) || document.getElementById('chatbuffer');
if (!buffer) return;
var anchor = t.closest('.msgwrap');
e.preventDefault();
e.stopPropagation();
focusThread(nick, buffer, anchor);
}, true);
// ---------------------------------------------------------------------------
// Attach to buffers + user list; process messages as they arrive.
// ---------------------------------------------------------------------------
function seedFromChatlist() {
document.querySelectorAll('#chatlist [nick]').forEach(function (el) { registerName(el.getAttribute('nick')); });
}
function attachBuffer(buffer) {
if (buffer._mtnObserved) return;
buffer._mtnObserved = true;
buffer.querySelectorAll('.msgwrap').forEach(processMsgwrap);
new MutationObserver(function (muts) {
muts.forEach(function (mut) {
mut.addedNodes.forEach(function (node) {
if (node.nodeType !== 1) return;
if (node.classList && node.classList.contains('msgwrap')) processMsgwrap(node);
else if (node.querySelectorAll) node.querySelectorAll('.msgwrap').forEach(processMsgwrap);
});
});
// Counteract BerryTube's auto-scroll-to-bottom while a thread is open.
if (activeBuffer === buffer && activeNick) keepFocusPinned();
}).observe(buffer, { childList: true });
}
function scan() {
injectStyle();
seedFromChatlist();
document.querySelectorAll('.chatbuffer, #chatbuffer').forEach(attachBuffer);
}
scan();
setInterval(scan, 3000);
console.log('[MentionThreads] v0.2.1 loaded');
})();