// ==UserScript==
// @name TorrentBD Shoutbox Mentions
// @namespace http://tampermonkey.net/
// @version 1.7
// @description Client-side @mention autocomplete + click-to-mention for TorrentBD shoutbox.
// @author JeTexY
// @namespace JeTexY
// @match https://*.torrentbd.com/*
// @match https://*.torrentbd.net/*
// @match https://*.torrentbd.org/*
// @license MIT
// @run-at document-end
// @icon https://static.torrentbd.net/bf68ee5a32904d2ca12f3050f9efbf91.png
// @grant none
// ==/UserScript==
(function () {
'use strict';
// -------------------------
// Utilities
// -------------------------
function waitFor(selector, { root = document, timeout = 15000 } = {}) {
return new Promise((resolve, reject) => {
const el = root.querySelector(selector);
if (el) return resolve(el);
const obs = new MutationObserver(() => {
const found = root.querySelector(selector);
if (found) {
obs.disconnect();
resolve(found);
}
});
obs.observe(root, { childList: true, subtree: true });
if (timeout) {
setTimeout(() => {
obs.disconnect();
resolve(null);
}, timeout);
}
});
}
function uniqKeepOrder(arr) {
const s = new Set();
const out = [];
for (const x of arr) if (!s.has(x)) { s.add(x); out.push(x); }
return out;
}
// replace last mention token before caret, or last mention in string, or insert at caret
function replaceLastMentionOrInsert(inputEl, username) {
const text = inputEl.value;
const caret = inputEl.selectionStart;
const before = text.slice(0, caret);
const after = text.slice(caret);
// 1) if there's an '@' token immediately before caret, replace that
const atMatch = before.match(/@([^\s@]*)$/);
if (atMatch) {
const start = before.lastIndexOf('@' + atMatch[1]);
const newBefore = before.slice(0, start) + '@' + username + ' ';
inputEl.value = newBefore + after;
const newCaret = newBefore.length;
inputEl.setSelectionRange(newCaret, newCaret);
return;
}
// 2) otherwise, replace the last mention anywhere in the string
const all = Array.from(text.matchAll(/@([^\s@]+)/g));
if (all.length > 0) {
const last = all[all.length - 1];
const s = last.index;
const e = s + last[0].length;
const newText = text.slice(0, s) + '@' + username + ' ' + text.slice(e);
inputEl.value = newText;
const newCaret = s + ('@' + username + ' ').length;
inputEl.setSelectionRange(newCaret, newCaret);
return;
}
// 3) no mentions at all: insert at caret
const newBefore = before + '@' + username + ' ';
inputEl.value = newBefore + after;
const newCaret = newBefore.length;
inputEl.setSelectionRange(newCaret, newCaret);
}
function appendMentionToEnd(inputEl, username) {
let v = inputEl.value;
if (!/\s$/.test(v)) v = v + ' ';
v = v + '@' + username + ' ';
inputEl.value = v;
inputEl.setSelectionRange(v.length, v.length);
}
// measure text width for dropdown sizing
function measureTextWidth(text, font) {
const ctx = measureTextWidth._ctx || (measureTextWidth._ctx = document.createElement('canvas').getContext('2d'));
ctx.font = font || getComputedStyle(document.body).font || '13px Arial';
return ctx.measureText(text).width;
}
// -------------------------
// Styles (dropdown + gradient border)
// -------------------------
const injectedCss = `
#tbd-mention-dropdown {
position: absolute;
background: #1e1e1e;
color: #e6e6e6;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.04);
box-shadow: 0 10px 30px rgba(0,0,0,0.6);
font-size: 13px;
max-height: 260px;
overflow-y: auto;
z-index: 2147483000;
display: none;
padding: 6px 0;
white-space: nowrap;
}
#tbd-mention-dropdown .tbd-mention-item {
padding: 6px 12px;
cursor: pointer;
user-select: none;
border-radius: 6px;
}
#tbd-mention-dropdown .tbd-mention-item.tbd-active {
background: linear-gradient(90deg, rgba(255,110,196,0.14), rgba(120,115,245,0.12));
color: #fff;
}
/* scrollbars */
#tbd-mention-dropdown::-webkit-scrollbar { width: 8px; }
#tbd-mention-dropdown::-webkit-scrollbar-track { background: transparent; }
#tbd-mention-dropdown::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 8px; }
/* light mode fallback */
@media (prefers-color-scheme: light) {
#tbd-mention-dropdown { background: #fff; color: #111; border: 1px solid rgba(0,0,0,0.08); }
#tbd-mention-dropdown .tbd-mention-item.tbd-active { background: linear-gradient(90deg, rgba(255,110,196,0.14), rgba(120,115,245,0.06)); color: #111; }
#tbd-mention-dropdown::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.08); }
}
/* gradient border around the .shout-user span on hover (works both themes) */
.tbd-user-border {
position: relative !important;
display: inline-block !important;
border-radius: 6px !important;
padding: 0 2px !important;
}
.tbd-user-border::before {
content: "";
position: absolute;
inset: -3px;
border-radius: 8px;
padding: 3px;
background: linear-gradient(135deg, #ff6b6b, #f8e71c, #7ed321, #50e3c2, #4a90e2, #bd10e0);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0.98;
}
`;
const styleTag = document.createElement('style');
styleTag.textContent = injectedCss;
document.head.appendChild(styleTag);
// -------------------------
// Main logic
// -------------------------
(async function main() {
// wait for input and shout container
const input = await waitFor('#shout_text', { timeout: 15000 });
const shoutsContainer = await waitFor('#shouts-container', { timeout: 15000 });
if (!input || !shoutsContainer) {
// site layout not present yet
return;
}
// create dropdown element
const dropdown = document.createElement('div');
dropdown.id = 'tbd-mention-dropdown';
document.body.appendChild(dropdown);
let dropdownOpen = false;
let suggestions = []; // current list of usernames
let activeIndex = -1;
function getUsernamesFromDOM() {
// collect .tbdrank elements in document order (shout newest at top typically)
const els = Array.from(shoutsContainer.querySelectorAll('.tbdrank'));
const names = els.map(el => {
// get the visible username text (strip newlines/spaces)
return (el.textContent || '').trim().replace(/\s+/g, ' ');
}).filter(Boolean);
return uniqKeepOrder(names);
}
// build dropdown items
function buildDropdown(list, openFresh = false) {
suggestions = list.slice(); // copy
dropdown.innerHTML = '';
for (let i = 0; i < list.length; i++) {
const item = document.createElement('div');
item.className = 'tbd-mention-item';
item.textContent = list[i];
// mousedown so we capture selection before blur
item.addEventListener('mousedown', (ev) => {
ev.preventDefault();
ev.stopPropagation();
selectSuggestion(i);
});
// touch support
item.addEventListener('touchstart', (ev) => {
ev.preventDefault();
selectSuggestion(i);
}, { passive: false });
dropdown.appendChild(item);
}
if (list.length === 0) {
closeDropdown();
return;
}
// width auto-size to longest string
const font = getComputedStyle(dropdown).font || '13px Arial';
const longest = list.reduce((m, s) => Math.max(m, measureTextWidth(s, font)), 0);
const padding = 36; // left+right + internal
const w = Math.round(Math.min(Math.max(longest + padding, 120), 420)); // clamp 120..420px
dropdown.style.width = `${w}px`;
// position dropdown under input (left aligned)
const rect = input.getBoundingClientRect();
dropdown.style.left = (rect.left + window.scrollX) + 'px';
dropdown.style.top = (rect.bottom + 4 + window.scrollY) + 'px';
// if opening fresh, highlight first; if already open, preserve index if possible
if (!dropdownOpen || openFresh) {
activeIndex = 0;
} else {
if (activeIndex >= list.length) activeIndex = list.length - 1;
if (activeIndex < 0) activeIndex = 0;
}
updateActiveItem();
dropdown.style.display = 'block';
dropdownOpen = true;
}
function updateActiveItem() {
const items = Array.from(dropdown.querySelectorAll('.tbd-mention-item'));
items.forEach((el, idx) => el.classList.toggle('tbd-active', idx === activeIndex));
// ensure visible
const sel = items[activeIndex];
if (sel) sel.scrollIntoView({ block: 'nearest' });
}
function closeDropdown() {
dropdown.style.display = 'none';
dropdownOpen = false;
suggestions = [];
activeIndex = -1;
}
function selectSuggestion(index) {
if (!suggestions || index < 0 || index >= suggestions.length) return;
const name = suggestions[index];
// on normal selection, replace last mention token or last mention in entire input
replaceLastMentionOrInsert(input, name);
// hide dropdown
closeDropdown();
input.focus();
}
// Input handling: show dropdown when user types @ and there's a token
let lastQuery = null;
input.addEventListener('input', (ev) => {
const caret = input.selectionStart;
const before = input.value.slice(0, caret);
const m = before.match(/@([^\s@]*)$/); // '@' followed by non-space, non-@ characters until caret
if (!m) {
closeDropdown();
lastQuery = null;
return;
}
const q = m[1].toLowerCase();
// fetch usernames fresh from DOM
const all = getUsernamesFromDOM();
// filter by partial substring match (case-insensitive)
const filtered = all.filter(name => name.toLowerCase().includes(q));
// If dropdown already open and query didn't change list length or items, preserve selection
const openFresh = (lastQuery === null || lastQuery !== q || !dropdownOpen);
buildDropdown(filtered, openFresh);
lastQuery = q;
});
// Key navigation (keydown to prevent default Tab focus moves)
input.addEventListener('keydown', (ev) => {
if (!dropdownOpen) {
// if user pressed '@' key, we let input event handle showing dropdown
// but for safety if they press '@' (Shift+2) we schedule input handler
if (ev.key === '@') {
setTimeout(() => input.dispatchEvent(new Event('input', { bubbles: true })), 0);
}
return;
}
const items = dropdown.querySelectorAll('.tbd-mention-item');
if (items.length === 0) return;
if (ev.key === 'ArrowDown') {
ev.preventDefault();
ev.stopPropagation();
activeIndex = (activeIndex + 1) % items.length;
updateActiveItem();
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
ev.stopPropagation();
activeIndex = (activeIndex - 1 + items.length) % items.length;
updateActiveItem();
} else if (ev.key === 'Tab') {
ev.preventDefault();
ev.stopPropagation();
if (ev.shiftKey) activeIndex = (activeIndex - 1 + items.length) % items.length;
else activeIndex = (activeIndex + 1) % items.length;
updateActiveItem();
} else if (ev.key === 'Enter') {
ev.preventDefault();
ev.stopPropagation();
selectSuggestion(activeIndex);
} else if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
closeDropdown();
}
});
// close dropdown on outside clicks (but allow clicking dropdown items - they use mousedown)
document.addEventListener('click', (ev) => {
if (dropdownOpen && !dropdown.contains(ev.target) && ev.target !== input) {
closeDropdown();
}
});
// -------------------------
// Hover border + click-to-mention on each shout-item
// -------------------------
function bindMessage(msg) {
if (!msg || msg.dataset.tbdBound === '1') return;
msg.dataset.tbdBound = '1';
// find .shout-user span (the container that holds tbdrank)
const userSpan = msg.querySelector('.shout-user');
const tbdrank = msg.querySelector('.tbdrank');
if (!userSpan || !tbdrank) return;
// username text
const username = (tbdrank.textContent || '').trim().replace(/\s+/g, ' ');
if (!username) return;
// hover: add gradient border class on userSpan
msg.addEventListener('mouseenter', () => {
userSpan.classList.add('tbd-user-border');
});
msg.addEventListener('mouseleave', () => {
userSpan.classList.remove('tbd-user-border');
});
// clicking on message: left click -> insert (or replace last mention); ctrl/cmd + left click -> append
msg.addEventListener('click', (ev) => {
// Only handle clicks if inside #shoutbox-container
const shoutboxContainer = document.getElementById('shoutbox-container');
if (
!shoutboxContainer ||
!msg.closest('#shoutbox-container')
) {
return;
}
if (ev.button !== 0) return; // left click only
// If clicking a link inside .shout-user or .shout-text, do nothing (let default happen)
const isUserOrText = ev.target.closest('.shout-user, .shout-text');
const isLink = ev.target.closest('a');
if (isUserOrText && isLink) {
// Let browser handle link navigation, do not insert mention
return;
}
// If clicking on .shout-delete or .material-icons, do nothing (let default happen)
if (
ev.target.closest('.shout-delete') ||
ev.target.closest('.material-icons')
) {
return;
}
// if user clicked a link inside the message, prevent navigation so click acts as mention
const a = ev.target.closest('a');
if (a) {
ev.preventDefault();
}
ev.stopPropagation();
const isMulti = ev.ctrlKey || ev.metaKey;
if (isMulti) {
appendMentionToEnd(input, username);
} else {
replaceLastMentionOrInsert(input, username);
}
input.focus();
closeDropdown();
}, true);
}
function bindAllMessagesNow() {
const msgs = Array.from(shoutsContainer.querySelectorAll('.shout-item'));
msgs.forEach(bindMessage);
}
// initial bind
bindAllMessagesNow();
// observe shout container for new messages (and for removed ones)
const mo = new MutationObserver(() => {
// rebind new messages (existing ones are skipped because of dataset flag)
bindAllMessagesNow();
// if dropdown is open, refresh suggestions from current DOM (no cache)
if (dropdownOpen) {
// recompute based on lastQuery (so if items removed from DOM they disappear)
const caret = input.selectionStart;
const before = input.value.slice(0, caret);
const m = before.match(/@([^\s@]*)$/);
const q = m ? m[1].toLowerCase() : null;
if (q !== null) {
const all = getUsernamesFromDOM();
const filtered = all.filter(name => name.toLowerCase().includes(q));
// rebuild preserving index (openFresh = false)
buildDropdown(filtered, false);
} else {
closeDropdown();
}
}
});
mo.observe(shoutsContainer, { childList: true, subtree: true });
// adjust dropdown position on scroll/resize
function repositionIfOpen() {
if (!dropdownOpen) return;
const rect = input.getBoundingClientRect();
dropdown.style.left = (rect.left + window.scrollX) + 'px';
dropdown.style.top = (rect.bottom + 4 + window.scrollY) + 'px';
}
window.addEventListener('resize', repositionIfOpen);
window.addEventListener('scroll', repositionIfOpen, true);
// done
})();
})();