CB Quick Silence

Pulls auto-silence usernames + trigger words from Supabase periodically. CSP-safe via GM_xmlhttpRequest. Adds Silence button (if mod), auto-silences or highlights. SPA-safe.

// ==UserScript==
// @name         CB Quick Silence
// @namespace    aravvn.tools
// @version      2.0.5
// @description  Pulls auto-silence usernames + trigger words from Supabase periodically. CSP-safe via GM_xmlhttpRequest. Adds Silence button (if mod), auto-silences or highlights. SPA-safe.
// @author       aravvn
// @license      CC-BY-NC-SA-4.0
// @match        https://*.chaturbate.com/*
// @run-at       document-idle
// @noframes
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      *.supabase.co
// @connect      supabase.co
// ==/UserScript==

(() => {
  'use strict';

  // ----------- CONFIG -----------
  const SUPABASE_URL = 'https://gbscowfidfdiaywktqal.supabase.co';
  const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imdic2Nvd2ZpZGZkaWF5d2t0cWFsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYxNDU0MzUsImV4cCI6MjA3MTcyMTQzNX0.BITW_rG0-goeo8VCgl-ZTSWfa8BDYsmT2xhIg-9055g';
  const SUPABASE_TABLE_USERS = 'auto_silence_users';
  const SUPABASE_TABLE_WORDS = 'trigger_words';
  const DEFAULT_POLL_MS = 300_000;

  const FALLBACK_USERS = [];
  const FALLBACK_WORDS = [];

  const CONFIG_DEFAULTS = {
    enableAutoSilence: true,
    enableHighlighting: true,
    showSilenceButtons: true,
    pollMs: DEFAULT_POLL_MS,
  };

  // ----------- LOG/UTIL -----------
  const DEBUG = true;
  const log = (...a) => DEBUG && console.log('[QS]', ...a);
  const getCfg = (k) => GM_getValue(k, CONFIG_DEFAULTS[k]);
  const setCfg = (k, v) => GM_setValue(k, v);

  function toast(msg) {
    const t = document.createElement('div');
    t.className = 'qs_toast';
    t.textContent = msg;
    document.body.appendChild(t);
    requestAnimationFrame(() => t.classList.add('show'));
    setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 180); }, 1600);
  }
  function stripTs(s) { return String(s || '').replace(/^[\[]\d{2}:\d{2}[]]\s*/, '').trim(); }
  function getCookie(name) {
    return document.cookie.split('; ').reduce((r, v) => {
      const parts = v.split('=');
      return parts[0] === name ? decodeURIComponent(parts[1]) : r;
    }, '');
  }
  function getRoomFromURL() {
    const first = location.pathname.replace(/^\/+|\/+$/g, '').split('/')[0] || '';
    return (!first || ['b','p','tags','api','auth','proxy'].includes(first)) ? '' : first;
  }
  function waitForSelector(selector, timeout = 5000) {
    return new Promise((resolve) => {
      const found = document.querySelector(selector);
      if (found) return resolve(found);
      const obs = new MutationObserver(() => {
        const el = document.querySelector(selector);
        if (el) { obs.disconnect(); resolve(el); }
      });
      obs.observe(document.documentElement, { childList: true, subtree: true });
      setTimeout(() => { obs.disconnect(); resolve(null); }, timeout);
    });
  }

  // ----------- DOM/CSS -----------
  const SEL = {
    rootMessage: 'div[data-testid="chat-message"]',
    usernameContainer: 'div[data-testid="chat-message-username"]',
    username: 'span[data-testid="username"]',
    viewerUsername: '.user_information_header_username, [data-testid="user-information-username"]',
  };

  const style = document.createElement('style');
  style.textContent = `
    .qs_sil_btn{display:inline-block;margin-left:6px;padding:0 6px;font:11px/18px system-ui,sans-serif;border-radius:4px;cursor:pointer;user-select:none;background:#d13b3b;color:#fff;border:0}
    .qs_sil_btn:hover{filter:brightness(1.06)}
    .qs_toast{position:fixed;right:12px;bottom:12px;z-index:2147483647;background:#151515;color:#e9e9ea;border:1px solid #3a3a3d;border-radius:8px;padding:8px 10px;font:12px system-ui,sans-serif;box-shadow:0 6px 18px rgba(0,0,0,.45);opacity:0;transform:translateY(8px);transition:all .2s ease}
    .qs_toast.show{opacity:1;transform:translateY(0)}
    .qs_flagged_user { border-left: 4px solid #f40 !important; background: rgba(255,80,80,0.08) !important; }
    .qs_flagged_word { border-left: 4px solid #f93 !important; background: rgba(255,160,60,0.08) !important; }
  `;
  document.head.appendChild(style);

  // ----------- GM_xmlhttpRequest wrapper (CSP-safe) -----------
  function gmRequestJSON(method, url, headers = {}, data = null) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method,
        url,
        headers,
        data,
        responseType: 'json',
        onload: (res) => {
          const status = res.status;
          const text = res.responseText;
          if (status >= 200 && status < 300) {
            try {
              resolve(JSON.parse(text || 'null'));
            } catch {
              resolve(res.response ?? null);
            }
          } else {
            reject(new Error(`HTTP ${status}: ${text?.slice(0, 200)}`));
          }
        },
        onerror: (e) => reject(new Error('Network error')),
        ontimeout: () => reject(new Error('Timeout')),
      });
    });
  }

  const hasSupabase = () =>
    SUPABASE_URL.startsWith('https://') &&
    SUPABASE_ANON_KEY &&
    SUPABASE_ANON_KEY.length > 20;

  function buildUrl(path, params = {}) {
    const url = new URL(`${SUPABASE_URL}/rest/v1/${path}`);
    const defaultParams = { select: '*', order: 'updated_at.desc', limit: '10000' };
    const q = new URLSearchParams({ ...defaultParams, ...params });
    url.search = q.toString();
    return url.toString();
  }

  async function sbGet(path, params = {}) {
    const url = buildUrl(path, params);
    return gmRequestJSON('GET', url, {
      apikey: SUPABASE_ANON_KEY,
      Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
      Accept: 'application/json',
    });
  }

  async function fetchUsers() {
    const rows = await sbGet(SUPABASE_TABLE_USERS, { select: 'username,is_active,updated_at', 'is_active': 'eq.true' });
    return rows
      .filter(r => r && r.username)
      .map(r => String(r.username).toLowerCase().trim())
      .filter(Boolean);
  }
  async function fetchWords() {
    const rows = await sbGet(SUPABASE_TABLE_WORDS, { select: 'word,is_active,updated_at', 'is_active': 'eq.true' });
    return rows
      .filter(r => r && r.word)
      .map(r => String(r.word).toLowerCase().trim())
      .filter(Boolean);
  }

  // ----------- State -----------
  let AUTO_SILENCE_USERS = [...FALLBACK_USERS.map(s => s.toLowerCase())];
  let TRIGGER_WORDS = [...FALLBACK_WORDS.map(s => s.toLowerCase())];
  let lastUsersSig = JSON.stringify(AUTO_SILENCE_USERS);
  let lastWordsSig = JSON.stringify(TRIGGER_WORDS);
  let pollTimer = null;

  function applyNewLists(users, words) {
  let changed = false;

  if (users) {
    const mergedUsers = Array.from(new Set([...FALLBACK_USERS.map(s => s.toLowerCase()), ...users]));
    AUTO_SILENCE_USERS = mergedUsers;
    changed = true;
  }

  if (words) {
    const mergedWords = Array.from(new Set([...FALLBACK_WORDS.map(s => s.toLowerCase()), ...words]));
    TRIGGER_WORDS = mergedWords;
    changed = true;
  }

  if (changed) {
    rescanExistingForHighlights();
  }
}

  async function syncFromSupabaseOnce() {
    if (!hasSupabase()) return;
    try {
      const [users, words] = await Promise.all([fetchUsers(), fetchWords()]);
      applyNewLists(users, words);
      setCfg('sb_last_ok', Date.now());
    } catch (e) {
      log('Supabase sync failed:', e.message || e);
      const lastOk = GM_getValue('sb_last_ok', 0);
      if (!lastOk) toast('⚠️ Supabase unreachable, using fallback lists');
    }
  }

  function startPolling() {
    if (pollTimer) clearInterval(pollTimer);
    const ms = Math.max(10_000, Number(getCfg('pollMs')) || DEFAULT_POLL_MS);
    pollTimer = setInterval(syncFromSupabaseOnce, ms);
  }

  // ----------- CB actions / logic -----------
  async function fetchIsPrivileged(viewer, room) {
    const url = `${location.origin}/api/ts/chatmessages/user_info/${encodeURIComponent(viewer)}/?room=${encodeURIComponent(room)}`;
    try {
      const res = await fetch(url, { credentials: 'include', headers: { 'Accept': 'application/json' } });
      const data = await res.json();
      return !!(data?.user?.is_mod || data?.user?.is_broadcaster);
    } catch {
      return false;
    }
  }
  async function postSilence(username) {
    const url = `${location.origin}/roomsilence/${encodeURIComponent(username)}${location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'}`;
    const csrf = getCookie('csrftoken') || getCookie('CSRF-TOKEN') || getCookie('XSRF-TOKEN');
    const headers = {
      'X-Requested-With': 'XMLHttpRequest',
      'Accept': '*/*',
      'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
      ...(csrf ? { 'X-CSRFToken': csrf, 'X-CSRF-Token': csrf } : {}),
      'Referer': location.href
    };
    const body = 'source=chat';
    const res = await fetch(url, { method: 'POST', credentials: 'include', headers, body, redirect: 'manual' });
    return res.ok;
  }
  function getUsernameFromRoot(root) {
    const span = root.querySelector(SEL.username) || root.querySelector(`${SEL.usernameContainer} ${SEL.username}`);
    if (span && span.textContent) return stripTs(span.textContent);
    const cont = root.querySelector(SEL.usernameContainer);
    const raw = cont?.textContent || '';
    const idx = raw.lastIndexOf(']');
    return stripTs(idx >= 0 ? raw.slice(idx + 1) : raw);
  }

  let running = false;
  let currentRoom = '';
  let isPrivileged = false;

  function mountButtonForMessage(root) {
    if (!(root instanceof HTMLElement) || root.dataset.qsBtn === '1') return;
    const nameWrap = root.querySelector(SEL.usernameContainer);
    if (!nameWrap) return;
    const username = getUsernameFromRoot(root);
    if (!username) return;
    const usernameLc = username.toLowerCase();
    const msgText = root.textContent.toLowerCase();
    const triggeredByWord = TRIGGER_WORDS.find(word => word && msgText.includes(word));
    const triggeredByUser = AUTO_SILENCE_USERS.includes(usernameLc);

    if (isPrivileged && getCfg('showSilenceButtons')) {
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'qs_sil_btn';
      btn.textContent = 'Silence';
      btn.addEventListener('click', async (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        const ok = await postSilence(username);
        toast(ok ? `🛑 Silenced @${username}` : `⚠️ Forbidden @${username}`);
        root.setAttribute('data-qs-silenced', ok ? '1' : '0');
      });
      nameWrap.appendChild(btn);
      root.dataset.qsBtn = '1';
    }

    if ((triggeredByUser || triggeredByWord) && root.getAttribute('data-qs-silenced') !== '1') {
      if (isPrivileged && getCfg('enableAutoSilence')) {
        postSilence(username).then(ok => {
          toast(ok
            ? `🤫 Auto-silenced @${username}${triggeredByWord ? ` for "${triggeredByWord}"` : ''}`
            : `⚠️ Auto-silence failed @${username}`);
          root.setAttribute('data-qs-silenced', ok ? '1' : '0');
        });
      } else if (!isPrivileged && getCfg('enableHighlighting')) {
        if (triggeredByUser) root.classList.add('qs_flagged_user');
        if (triggeredByWord) root.classList.add('qs_flagged_word');
        root.setAttribute('data-qs-silenced', '0');
      }
    }
  }

  function scanExisting() {
    document.querySelectorAll(SEL.rootMessage).forEach(mountButtonForMessage);
  }
  function rescanExistingForHighlights() {
    if (!getCfg('enableHighlighting')) return;
    document.querySelectorAll(SEL.rootMessage).forEach(root => {
      root.classList.remove('qs_flagged_user','qs_flagged_word');
      root.removeAttribute('data-qs-silenced');
      mountButtonForMessage(root);
    });
  }

  const msgObserver = new MutationObserver((muts) => {
    for (const m of muts) for (const node of m.addedNodes) {
      if (!(node instanceof HTMLElement)) continue;
      if (node.matches?.(SEL.rootMessage)) {
        mountButtonForMessage(node);
      } else {
        node.querySelectorAll?.(SEL.rootMessage).forEach(mountButtonForMessage);
      }
    }
  });

  function stopAll() {
    if (!running) return;
    msgObserver.disconnect();
    document.querySelectorAll('.qs_sil_btn').forEach(btn => btn.remove());
    running = false;
    isPrivileged = false;
  }

  async function startForRoom(room) {
    stopAll();
    const viewerEl = await waitForSelector(SEL.viewerUsername, 5000);
    const viewer = viewerEl?.textContent?.trim();
    if (!viewer) return;
    isPrivileged = await fetchIsPrivileged(viewer, room);
    log('Viewer:', viewer, 'isPrivileged:', isPrivileged);
    running = true;

    if (hasSupabase()) {
      await syncFromSupabaseOnce();
      startPolling();
    } else {
      toast('ℹ️ Using local fallback lists (no Supabase config)');
    }

    scanExisting();
    msgObserver.observe(document.body, { childList: true, subtree: true });
  }

  function handleRoomEnter() {
    const room = getRoomFromURL();
    if (!room || room === currentRoom) return;
    currentRoom = room;
    startForRoom(room);
  }

  // SPA locationchange hook
  (() => {
    const _ps = history.pushState, _rs = history.replaceState;
    history.pushState = function (...a) { const r = _ps.apply(this, a); window.dispatchEvent(new Event('locationchange')); return r; };
    history.replaceState = function (...a) { const r = _rs.apply(this, a); window.dispatchEvent(new Event('locationchange')); return r; };
    window.addEventListener('popstate', () => window.dispatchEvent(new Event('locationchange')));
    window.addEventListener('locationchange', handleRoomEnter);
  })();

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', handleRoomEnter, { once: true });
  } else {
    handleRoomEnter();
  }

  // Menüs
  GM_registerMenuCommand(`[Toggle] Auto Silence: ${getCfg('enableAutoSilence') ? '✅ ON' : '❌ OFF'}`, () => {
    setCfg('enableAutoSilence', !getCfg('enableAutoSilence'));
    toast(`Auto Silence is now ${getCfg('enableAutoSilence') ? '✅ ON' : '❌ OFF'}`);
  });
  GM_registerMenuCommand(`[Toggle] Highlight Matches: ${getCfg('enableHighlighting') ? '✅ ON' : '❌ OFF'}`, () => {
    setCfg('enableHighlighting', !getCfg('enableHighlighting'));
    toast(`Highlighting is now ${getCfg('enableHighlighting') ? '✅ ON' : '❌ OFF'}`);
    rescanExistingForHighlights();
  });
  GM_registerMenuCommand(`[Toggle] Show Buttons (mods only): ${getCfg('showSilenceButtons') ? '✅ ON' : '❌ OFF'}`, () => {
    setCfg('showSilenceButtons', !getCfg('showSilenceButtons'));
    toast(`Silence buttons are now ${getCfg('showSilenceButtons') ? '✅ ON' : '❌ OFF'}`);
  });
  GM_registerMenuCommand(`Sync now (Supabase)`, async () => {
    await syncFromSupabaseOnce();
  });
  GM_registerMenuCommand(`Polling interval: ${(getCfg('pollMs')/1000)|0}s (click to change)`, () => {
    const cur = Number(getCfg('pollMs')) || DEFAULT_POLL_MS;
    const s = prompt('Polling interval in seconds (min 10s):', String((cur/1000)|0));
    if (s === null) return;
    const sec = Math.max(10, Number(s) || 60);
    setCfg('pollMs', sec * 1000);
    startPolling();
    toast(`⏱️ Polling every ${sec}s`);
  });

})();