TorrentBD Shoutbox Mentions

Client-side @mention autocomplete + click-to-mention for TorrentBD shoutbox.

当前为 2025-08-26 提交的版本,查看 最新版本

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

})();