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. Includes a menu to view DB auto-silenced users.

// ==UserScript==
// @name         CB Quick Silence
// @namespace    aravvn.tools
// @version      2.2.0
// @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. Includes a menu to view DB auto-silenced users.
// @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; }

    /* Overlay (DB list) */
    #qs_db_overlay{position:fixed;inset:auto 16px 16px auto;right:16px;bottom:16px;width:min(560px,92vw);max-height:min(72vh,680px);z-index:2147483647;background:#101014cc;color:#e9e9ea;border:1px solid #3a3a3d;border-radius:10px;backdrop-filter:blur(4px);box-shadow:0 10px 28px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}
    #qs_db_overlay header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid #2a2a2e;font:600 13px system-ui,sans-serif}
    #qs_db_overlay .qs_actions{display:flex;gap:8px}
    #qs_db_overlay button{background:#2a2a2e;border:1px solid #3a3a3d;border-radius:6px;color:#e9e9ea;font:12px/26px system-ui,sans-serif;padding:0 10px;cursor:pointer}
    #qs_db_overlay button:hover{filter:brightness(1.08)}
    #qs_db_overlay .qs_body{padding:8px 12px;overflow:auto}
    #qs_db_overlay table{width:100%;border-collapse:collapse;font:12px system-ui,sans-serif}
    #qs_db_overlay th,#qs_db_overlay td{border-bottom:1px solid #2a2a2e;padding:6px 4px;vertical-align:top;word-break:break-word}
    #qs_db_overlay th{position:sticky;top:0;background:#121216;z-index:1;text-align:left}
    #qs_db_overlay .qs_small{opacity:.65;font-size:11px}
    #qs_db_overlay .qs_empty{opacity:.65;padding:10px 4px}
  `;
  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: () => 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',
    });
  }

  // existing skinny lists (for logic)
  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);
  }

  // NEW: detailed fetch for overlay (robust to unknown columns)
  async function fetchUsersDetailed() {
    // We try to fetch common fields; if some don't exist, Supabase will still return others
    const rows = await sbGet(SUPABASE_TABLE_USERS, {
      select: '*',               // be pragmatic
      order: 'updated_at.desc',
      limit: '10000',
      'is_active': 'eq.true'     // only active entries
    });
    const norm = (v) => (v == null ? '' : String(v));
    return (rows || []).map(r => ({
      username: norm(r.username).toLowerCase(),
      note: norm(r.note || r.comment || r.description || ''),
      reason: norm(r.reason || ''),
      source: norm(r.source || r.origin || ''),
      is_active: (typeof r.is_active === 'boolean') ? r.is_active : true,
      created_at: r.created_at || r.inserted_at || '',
      updated_at: r.updated_at || r.modified_at || r.changed_at || '',
    })).filter(e => e.username);
  }

  // ----------- State -----------
  let AUTO_SILENCE_USERS = [...FALLBACK_USERS.map(s => s.toLowerCase())];
  let TRIGGER_WORDS = [...FALLBACK_WORDS.map(s => s.toLowerCase())];
  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);
  }

  // ----------- LOCAL log kept (manual + auto actually executed) -----------
  const SILENCE_DB_KEY = 'qs_silenced_users_db';
  function readSilenceDB() {
    const raw = GM_getValue(SILENCE_DB_KEY, {});
    return (raw && typeof raw === 'object') ? raw : {};
  }
  function writeSilenceDB(obj) { GM_setValue(SILENCE_DB_KEY, obj); }

  /**
   * Upsert a silenced entry (local record of actually posted silences).
   * @param {Object} entry { username, note, mode:'auto'|'manual', room, reason, ts }
   */
  function upsertSilenced(entry) {
    const db = readSilenceDB();
    const key = String(entry.username || '').toLowerCase();
    if (!key) return;
    const prev = db[key] || {};
    db[key] = {
      username: entry.username,
      firstTs: prev.firstTs || entry.ts,
      ts: entry.ts,
      note: entry.note || prev.note || '',
      mode: entry.mode || prev.mode || 'auto',
      reason: entry.reason || prev.reason || '',
      room: entry.room || prev.room || '',
      count: (prev.count || 0) + 1,
    };
    writeSilenceDB(db);
  }

  // ----------- 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);

    // manual silence button
    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 note = prompt('Optional note for this silence (reason, context):', '') || '';
        const ok = await postSilence(username);
        toast(ok ? `🛑 Silenced @${username}` : `⚠️ Forbidden @${username}`);
        root.setAttribute('data-qs-silenced', ok ? '1' : '0');
        if (ok) {
          upsertSilenced({
            username,
            note,
            mode: 'manual',
            reason: note ? note : 'manual',
            room: currentRoom,
            ts: Date.now(),
          });
        }
      });
      nameWrap.appendChild(btn);
      root.dataset.qsBtn = '1';
    }

    // auto
    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');
          if (ok) {
            const note = triggeredByUser
              ? 'listed user'
              : (triggeredByWord ? `trigger: "${triggeredByWord}"` : 'auto');
            upsertSilenced({
              username,
              note,
              mode: 'auto',
              reason: triggeredByWord || (triggeredByUser ? 'listed user' : ''),
              room: currentRoom,
              ts: Date.now(),
            });
          }
        });
      } 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();
  }

  // ----------- DB Overlay (auto_silence_users from Supabase) -----------
  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));
  }
  function fmtTs(ts) {
    if (!ts) return '';
    try {
      const d = new Date(ts);
      const p = (n)=>String(n).padStart(2,'0');
      return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
    } catch { return String(ts); }
  }
  function ensureDbOverlay() {
    let el = document.getElementById('qs_db_overlay');
    if (el) return el;
    el = document.createElement('div');
    el.id = 'qs_db_overlay';
    el.innerHTML = `
      <header>
        <span>DB: Auto-silence users (active)</span>
        <div class="qs_actions">
          <button type="button" data-action="refresh">Refresh</button>
          <button type="button" data-action="export">Export JSON</button>
          <button type="button" data-action="close">Close</button>
        </div>
      </header>
      <div class="qs_body"><div class="qs_empty">Loading…</div></div>
    `;
    el.querySelector('[data-action="close"]').addEventListener('click', () => el.remove());
    el.querySelector('[data-action="export"]').addEventListener('click', async () => {
      const data = await fetchUsersDetailed().catch(()=>[]);
      const pretty = JSON.stringify(data, null, 2);
      const w = window.open('', '_blank', 'noopener,noreferrer,width=700,height=700');
      if (w) {
        w.document.write(`<pre style="white-space:pre-wrap;font:12px/1.4 monospace;padding:12px;margin:0;background:#0e0e10;color:#e9e9ea">${escapeHtml(pretty)}</pre>`);
        w.document.close();
      } else {
        prompt('Copy JSON:', pretty);
      }
    });
    el.querySelector('[data-action="refresh"]').addEventListener('click', () => renderDbOverlay(el, true));
    document.body.appendChild(el);
    return el;
  }
  async function renderDbOverlay(el = ensureDbOverlay(), force = false) {
    const body = el.querySelector('.qs_body');
    body.innerHTML = `<div class="qs_empty">Loading…</div>`;
    try {
      if (!hasSupabase()) {
        body.innerHTML = `<div class="qs_empty">Supabase config missing.</div>`;
        return;
      }
      const rows = await fetchUsersDetailed();
      if (!rows.length) {
        body.innerHTML = `<div class="qs_empty">No active DB entries.</div>`;
        return;
      }
      // Sort by updated_at desc
      rows.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
      const html = [
        `<table>`,
        `<thead><tr><th style="width:30%">Username</th><th style="width:36%">Note/Reason</th><th style="width:14%">Source</th><th style="width:20%">Updated</th></tr></thead>`,
        `<tbody>`,
        ...rows.map(r => `
          <tr>
            <td><strong>@${escapeHtml(r.username)}</strong><div class="qs_small">${r.is_active ? 'active' : 'inactive'}</div></td>
            <td>${escapeHtml(r.note || r.reason || '')}</td>
            <td>${escapeHtml(r.source || '')}</td>
            <td><span title="created: ${escapeHtml(fmtTs(r.created_at))}">${escapeHtml(fmtTs(r.updated_at))}</span></td>
          </tr>
        `),
        `</tbody></table>`
      ].join('');
      body.innerHTML = html;
    } catch (e) {
      body.innerHTML = `<div class="qs_empty">Error loading DB list: ${escapeHtml(e.message||String(e))}</div>`;
    }
  }
  function openDbOverlay() {
    const el = ensureDbOverlay();
    renderDbOverlay(el, true);
  }

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

  // NEW: DB list (active auto-silence users)
  GM_registerMenuCommand(`Show DB auto-silence users`, () => {
    openDbOverlay();
  });

})();