Reddit Delete Chat Messages

Add UI to auto-mark and delete Reddit chat messages (with username awareness)

// ==UserScript==
// @name         Reddit Delete Chat Messages
// @namespace    http://tampermonkey.net/
// @version      3.0.2
// @description  Add UI to auto-mark and delete Reddit chat messages (with username awareness)
// @author       mrrobot
// @match        https://chat.reddit.com/*
// @match        https://www.reddit.com/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  // ============================== Config ==============================
  const CLICK_DELAY_MS = 800;              // pacing between actions
  const MENU_WAIT_MS = 350;
  const DIALOG_OPEN_WAIT_MS = 250;         // pause after clicking Delete before dialog search/confirm
  const HOVER_REVEAL_RETRIES = 10;
  const HOVER_REVEAL_INTERVAL_MS = 150;
  const PRE_MENU_EXTRA_WAIT_MS = 400;
  const BATCH_SIZE_DEFAULT = 10;

  // Auto-confirm dialog behavior
  const AUTO_CONFIRM_ENABLED = true;
  const AUTO_CONFIRM_COOLDOWN_MS = 900;    // min gap between auto-confirms
  const OBSERVER_TICK_MS = 80;             // throttle observer reaction
  const DIALOG_DISAPPEAR_TIMEOUT_MS = 8000;
  const NODE_REMOVAL_TIMEOUT_MS = 8000;

  // Continuous sweep tuning
  const SWEEP_BATCH_SIZE = 10;             // max deletes per sweep cycle
  const SWEEP_IDLE_PAUSE_MS = 500;         // pause between cycles
  const SCROLL_STEP_PX = 1200;             // upward scroll step to load older messages
  const SCROLL_SETTLE_MS = 650;            // wait after scrolling for virtualization to render
  const TOP_STALL_MAX = 4;                 // consecutive "at top & nothing new" cycles before stopping

  // LocalStorage key for saved username (avoid collisions across versions)
  const LS_KEY_USERNAME = 'rc_username_v30_public';

  // ============================ UI Panel =============================
  const panel = document.createElement('div');
  Object.assign(panel.style, {
    position:'fixed', right:'16px', bottom:'16px', zIndex:2147483647,
    background:'rgba(17,17,17,.96)', color:'#fff', padding:'12px', borderRadius:'12px',
    fontFamily:'ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial',
    fontSize:'12px', boxShadow:'0 8px 24px rgba(0,0,0,.35)', maxWidth:'600px'
  });
  const savedName = localStorage.getItem(LS_KEY_USERNAME) || '';
  panel.innerHTML = `
    <div style="font-weight:700;margin-bottom:6px">Reddit Chat — Username Aware v3.0.2</div>

    <div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-bottom:8px">
      <input id="rc-username" type="text" placeholder="Your Reddit username (no u/)"
             value="${savedName.replace(/"/g, '&quot;')}"
             style="padding:6px 8px;border-radius:8px;border:1px solid #444;background:#111;color:#fff;outline:none">
      <button id="rc-save" style="padding:6px 8px;border:none;border-radius:8px;cursor:pointer">Save Username</button>
    </div>

    <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
      <button id="rc-pick">Pick Container</button>
      <button id="rc-parent">Select Parent</button>
      <button id="rc-auto-you">Auto-Mark "You said"</button>
      <button id="rc-auto-user">Auto-Mark by Username</button>
      <button id="rc-mark">Toggle Mark Mode</button>
      <button id="rc-clear">Clear Marks</button>
      <button id="rc-del-one" style="background:#ef4444;color:#fff">Delete One Marked</button>
      <button id="rc-del-batch" style="background:#ef4444;color:#fff">Delete ${BATCH_SIZE_DEFAULT} Marked</button>
      <button id="rc-menulog">Toggle Menu Log</button>
      <button id="rc-dump">Debug Dump</button>
    </div>

    <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
      <button id="rc-sweep-start" style="background:#10b981;color:#001">Start Continuous Sweep</button>
      <button id="rc-sweep-stop"  style="background:#f59e0b;color:#001">Stop Sweep</button>
      <span id="rc-sweep-status" style="align-self:center;opacity:.9"></span>
    </div>

    <div style="opacity:.9;margin-bottom:6px">
      Tip: Save your username → <b>Pick Container</b> → Start Continuous Sweep (or manual: Auto-Mark + Delete).
      Auto-confirm is ${AUTO_CONFIRM_ENABLED ? '<b>ON</b>' : '<b>OFF</b>'}.
    </div>
    <div id="rc-log" style="max-height:360px;overflow:auto;white-space:pre-wrap;line-height:1.25"></div>
  `;
  for (const b of panel.querySelectorAll('button')) {
    Object.assign(b.style, { padding:'6px 8px', border:'none', borderRadius:'8px', cursor:'pointer' });
  }
  document.documentElement.appendChild(panel);

  const logEl = panel.querySelector('#rc-log');
  logEl.style.maxHeight = '50px';
  logEl.style.overflowY = 'auto';
  const statusEl = panel.querySelector('#rc-sweep-status');
  const inpUser = panel.querySelector('#rc-username');
  const btnSave = panel.querySelector('#rc-save');
  const btnPick = panel.querySelector('#rc-pick');
  const btnParent = panel.querySelector('#rc-parent');
  const btnAutoYou = panel.querySelector('#rc-auto-you');
  const btnAutoUser = panel.querySelector('#rc-auto-user');
  const btnMark = panel.querySelector('#rc-mark');
  const btnClear = panel.querySelector('#rc-clear');
  const btnDelOne = panel.querySelector('#rc-del-one');
  const btnDelBatch = panel.querySelector('#rc-del-batch');
  const btnMenu = panel.querySelector('#rc-menulog');
  const btnDump = panel.querySelector('#rc-dump');
  const btnSweepStart = panel.querySelector('#rc-sweep-start');
  const btnSweepStop = panel.querySelector('#rc-sweep-stop');

  const log = (m)=>{ const t=new Date().toLocaleTimeString(); logEl.textContent = `[${t}] ${m}\n` + (logEl.textContent||''); };

  // ============================ Utilities ============================
  function* deepNodes(root){
    yield root;
    const tw = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null);
    while (tw.nextNode()){
      const el = tw.currentNode;
      yield el;
      if (el.shadowRoot){ yield el.shadowRoot; yield* deepNodes(el.shadowRoot); }
      if (el.tagName === 'IFRAME'){
        try{ if (el.contentDocument){ yield el.contentDocument; yield* deepNodes(el.contentDocument); } }catch(_){}
      }
    }
  }
  function deepQueryAll(sel, scope=document){
    const out = [];
    for (const n of deepNodes(scope)) if (n.querySelectorAll) { try { out.push(...n.querySelectorAll(sel)); } catch(_) {} }
    return Array.from(new Set(out));
  }
  function localQuery(scope, sel){
    const out = [];
    (function walk(n){
      if (!n) return;
      if (n.querySelectorAll) { try { out.push(...n.querySelectorAll(sel)); } catch(_) {} }
      if (n.shadowRoot) walk(n.shadowRoot);
      for (const c of n.children || []) walk(c);
    })(scope);
    return out[0] || null;
  }
  function outline(el, css='2px solid #f59e0b'){ if (el){ el.style.outline=css; el.style.outlineOffset='0'; } }
  const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
  function isVisible(el){ if(!el) return false; const cs=getComputedStyle(el); const r=el.getBoundingClientRect(); return cs.display!=='none' && cs.visibility!=='hidden' && cs.opacity!=='0' && r.width>0 && r.height>0; }
  function pressEsc(){ document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',code:'Escape',keyCode:27,which:27,bubbles:true})); }

  // ====================== Container & Node Access =====================
  let selectedContainer=null;
  function countEvents(scope){ return deepQueryAll('rs-timeline-event', scope).length; }
  function getEvents(scope){ return deepQueryAll('rs-timeline-event', scope).filter(isVisible); }
  function bubbleToTimelineContainer(node){
    let cur=node, best=null, bestCount=0;
    for(let i=0;i<12 && cur;i++){
      const cnt = countEvents(cur);
      if (cnt>bestCount){ best=cur; bestCount=cnt; }
      cur = cur.parentNode instanceof ShadowRoot ? cur.parentNode.host : cur.parentElement;
    }
    return best||node;
  }
  function pickElementOnce(){
    return new Promise(res=>{
      panel.style.pointerEvents='none';
      const onKey=e=>{ if(e.key==='Escape') cleanup(null); };
      const onClick=e=>{ e.preventDefault(); e.stopPropagation(); const t=e.composedPath?e.composedPath()[0]:e.target; cleanup(t); };
      function cleanup(v){
        document.removeEventListener('click', onClick, true);
        document.removeEventListener('keydown', onKey, true);
        panel.style.pointerEvents=''; res(v);
      }
      document.addEventListener('click', onClick, true);
      document.addEventListener('keydown', onKey, true);
      log('Pick mode: click the message list (Esc cancels)…');
    });
  }

  // ======================== Scrolling Helpers ========================
  function getScrollable(){
    // Prefer inner virtual scroll container if present
    const vs = selectedContainer && localQuery(selectedContainer, 'rs-virtual-scroll-dynamic, rs-virtual-scroll, [class*="virtual-scroll"], [data-testid*="virtual"]');
    if (vs) return vs;
    // Otherwise, nearest ancestor that scrolls
    let cur = selectedContainer;
    for (let i=0; i<10 && cur; i++){
      const el = cur instanceof ShadowRoot ? cur.host : cur;
      const cs = getComputedStyle(el);
      if ((cs.overflowY==='auto' || cs.overflowY==='scroll') && el.scrollHeight > el.clientHeight) return el;
      cur = el.parentNode instanceof ShadowRoot ? el.parentNode.host : el.parentElement;
    }
    // Fallback to main/document
    const main = document.querySelector('main');
    if (main && main.scrollHeight > main.clientHeight) return main;
    return document.scrollingElement || document.documentElement || document.body;
  }
  function atTop(scrollEl){ return !scrollEl ? true : (scrollEl.scrollTop <= 2); }
  async function nudgeUp(scrollEl){
    if (!scrollEl) return;
    if (scrollEl.scrollTop > 0){
      scrollEl.scrollTop = Math.max(0, scrollEl.scrollTop - SCROLL_STEP_PX);
    } else {
      scrollEl.scrollTop = 1; await sleep(30); scrollEl.scrollTop = 0;
    }
    await sleep(SCROLL_SETTLE_MS);
  }

  // ==================== Marked ID Set (virtualization) ====================
  const markedIdSet = new Set();
  function getEventId(evt){
    const id = evt.getAttribute && evt.getAttribute('data-id');
    if (id) return id;
    const child = localQuery(evt, '[data-id]');
    return child ? child.getAttribute('data-id') : null;
  }
  function ensureMarksFromIdSet(){
    if (!selectedContainer || !markedIdSet.size) return;
    for (const evt of getEvents(selectedContainer)){
      const id = getEventId(evt);
      if (id && markedIdSet.has(id)){
        if (evt.dataset.rcMarked!=='true'){ setMarked(evt,true); }
      }
    }
  }

  // ====================== Marking & Ownership ======================
  let markMode=false;
  function setMarked(evt,on){
    if (on){ evt.dataset.rcMarked='true'; evt.style.boxShadow='0 0 0 2px #22c55e inset'; evt.style.outline='2px solid #22c55e'; }
    else { delete evt.dataset.rcMarked; evt.style.boxShadow=''; evt.style.outline=''; }
  }
  function toggleMark(evt){
    const on = !(evt.dataset.rcMarked==='true');
    setMarked(evt, on);
    const id = getEventId(evt);
    if (id){
      if (on) markedIdSet.add(id); else markedIdSet.delete(id);
    }
  }
  function attachMarkHandlers(container){
    for (const evt of getEvents(container)){
      if (evt._rcBound) continue;
      evt._rcBound = true;
      evt.addEventListener('click',(e)=>{
        if (!markMode) return;
        e.preventDefault(); e.stopPropagation();
        toggleMark(evt);
      }, true);
    }
  }
  function isAriaYouSaid(evt){
    const m = localQuery(evt, '.room-message[aria-label]');
    const aria = m ? (m.getAttribute('aria-label')||'') : '';
    return /^you said\b/i.test(aria.trim());
  }
  function isByUsername(evt, username){
    if (!username) return false;
    const uname = String(username).trim().toLowerCase();
    const m = localQuery(evt, '.room-message[aria-label]');
    const aria = m ? (m.getAttribute('aria-label')||'') : '';
    if (new RegExp(`^${uname}\\s+said\\b`, 'i').test(aria.trim())) return true;
    const n = localQuery(evt, '.user-name, [class*="user-name"], [data-testid="message-author"]');
    if (n){
      const txt = (n.innerText || n.textContent || '').trim().toLowerCase();
      if (txt === uname) return true;
    }
    return false;
  }
  function autoMarkYouSaid(){
    if (!selectedContainer){ log('Pick a container first.'); return 0; }
    let count=0;
    for (const evt of getEvents(selectedContainer)){
      if (isAriaYouSaid(evt)){
        setMarked(evt,true);
        const id = getEventId(evt); if (id) markedIdSet.add(id);
        count++;
      }
    }
    log(`Auto-Mark "You said": marked ${count}.`);
    return count;
  }
  function autoMarkByUsername(username){
    if (!selectedContainer){ log('Pick a container first.'); return 0; }
    if (!username){ log('No username set. Enter it above and click Save Username.'); return 0; }
    let count=0;
    for (const evt of getEvents(selectedContainer)){
      if (isByUsername(evt, username)){
        setMarked(evt,true);
        const id = getEventId(evt); if (id) markedIdSet.add(id);
        count++;
      }
    }
    log(`Auto-Mark by Username (“${username}”): marked ${count}.`);
    return count;
  }

  // =================== Action Bar / Menu Access ===================
  async function prepareForActions(evt){
    evt.scrollIntoView({block:'center'});
    evt.focus?.();
    evt.dispatchEvent(new MouseEvent('mouseenter',{bubbles:true}));
    await sleep(120);
    const inner = localQuery(evt,'[data-testid], rs-message, rs-bubble, div, span') || evt;
    inner.dispatchEvent(new MouseEvent('mouseenter',{bubbles:true}));
    await sleep(PRE_MENU_EXTRA_WAIT_MS);
  }
  async function openActions(evt){
    for (let i=0;i<HOVER_REVEAL_RETRIES;i++){
      await prepareForActions(evt);

      // direct trash
      let trash = localQuery(evt, [
        '[aria-label="Delete"]','[title="Delete"]','[data-testid="delete-message"]',
        'button:has(svg[aria-label="delete"])','rs-icon-button[icon="trash"]','rs-icon-button[icon="delete"]','rs-button[aria-label="Delete"]'
      ].join(','));
      if (trash && isVisible(trash)) return { directDelete: trash };

      // 3-dots
      let more = localQuery(evt, [
        '[aria-label="More options"]','[aria-label="More"]','[title="More"]',
        'button[aria-haspopup="menu"]','[role="button"][aria-haspopup="menu"]',
        'button:has(svg[aria-label="more"])','rs-icon-button[icon="more"]'
      ].join(','));
      if (more && isVisible(more)){
        more.click();
        await sleep(MENU_WAIT_MS);
        return { openedMenu: true };
      }

      // context menu fallback
      evt.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,button:2}));
      await sleep(MENU_WAIT_MS);
      const anyMenu = deepQueryAll('[role="menuitem"], rs-menu-item, rs-dropdown-item').length;
      if (anyMenu) return { openedMenu: true };

      await sleep(HOVER_REVEAL_INTERVAL_MS);
    }
    return {};
  }

  // ========================= Menu Helpers =========================
  let MENU_LOG = true;  // single declaration (no duplicates)
  function listMenuItems(){
    if(!MENU_LOG) return [];
    const items = deepQueryAll('[role="menuitem"], rs-menu-item, rs-dropdown-item');
    const lines = items.slice(0,40).map((el,i)=>`${i+1}. ${(el.getAttribute('aria-label')||'').trim()} "${(el.innerText||el.textContent||'').trim()}"`).join('\n');
    if(lines) log('Menu items:\n'+lines);
    return items;
  }
  function findDeleteMenuItem(){
    const items = deepQueryAll('[role="menuitem"], rs-menu-item, rs-dropdown-item');
    for(const el of items){
      const aria=(el.getAttribute('aria-label')||'').toLowerCase();
      const txt=(el.innerText||el.textContent||'').toLowerCase();
      if(aria.includes('delete') || txt.includes('delete')) return el;
      if(aria.includes('remove') || txt.includes('remove message') || txt.includes('remove')) return el;
    }
    return null;
  }

  // =================== Dialog Auto-Confirm (safe) ===================
  let confirmLock = false;
  let lastConfirmTs = 0;
  let activeDialog = null;
  let activeDialogVisible = false;
  let observerPending = false;

  function findVisibleDeleteDialog(){
    const rsDlg = deepQueryAll('rs-delete-message-modal rpl-dialog').find(isVisible);
    if (rsDlg) return rsDlg;
    const candidates = deepQueryAll('rpl-dialog, [role="dialog"], div').filter(isVisible);
    return candidates.find(el => {
      const txt = (el.innerText || '').toLowerCase();
      return txt.includes('delete this message?') || (txt.includes('delete') && txt.includes("you can't undo"));
    }) || null;
  }
  function clickYesDelete(dlg){
    const btns = deepQueryAll('button, [role="button"]', dlg);
    const yes = btns.find(b => /yes,\s*delete/i.test((b.innerText || b.textContent || '').trim()));
    if (yes){ yes.click(); return 'yes'; }
    const fallback = btns.find(b => /delete|confirm|yes/i.test((b.innerText||b.textContent||'').trim()));
    if (fallback){ fallback.click(); return 'fallback'; }
    return null;
  }
  async function waitDialogGone(timeoutMs=DIALOG_DISAPPEAR_TIMEOUT_MS){
    const start=performance.now();
    while(performance.now()-start<timeoutMs){
      const d = findVisibleDeleteDialog();
      if (!d){
        activeDialog = null;
        activeDialogVisible = false;
        return true;
      }
      await sleep(80);
    }
    return false;
  }
  async function tryAutoConfirm(reason='observer'){
    if (!AUTO_CONFIRM_ENABLED) return;
    if (confirmLock) return;
    const now = performance.now();
    if (now - lastConfirmTs < AUTO_CONFIRM_COOLDOWN_MS) return;

    const dlg = findVisibleDeleteDialog();
    if (!dlg){ activeDialog = null; activeDialogVisible = false; return; }
    if (dlg === activeDialog && activeDialogVisible) return;

    activeDialog = dlg;
    activeDialogVisible = true;

    confirmLock = true;
    try {
      const which = clickYesDelete(dlg);
      if (which){
        lastConfirmTs = performance.now();
        log(`Auto-confirm (${reason}): clicked ${which === 'yes' ? '"Yes, Delete"' : 'fallback confirm'}.`);
        await waitDialogGone();
      } else {
        log('Auto-confirm: confirm button not found.');
      }
    } catch (e){
      log('Auto-confirm error: ' + (e && e.message ? e.message : e));
    } finally {
      confirmLock = false;
    }
  }
  const modalObserver = new MutationObserver(() => {
    if (observerPending) return;
    observerPending = true;
    setTimeout(() => {
      observerPending = false;
      tryAutoConfirm('observer');
    }, OBSERVER_TICK_MS);
  });
  modalObserver.observe(document.documentElement, {subtree:true, childList:true});

  // ========================== Delete Core ==========================
  async function waitNodeRemoved(node, timeoutMs=NODE_REMOVAL_TIMEOUT_MS){
    const start = performance.now();
    while (performance.now() - start < timeoutMs){
      if (!node.isConnected) return true;
      await sleep(80);
    }
    return false;
  }
  async function deleteEvent(evt){
    const id = getEventId(evt);
    const open = await openActions(evt);

    if(open.directDelete){
      log('Clicking direct Delete…');
      open.directDelete.click();
      await sleep(DIALOG_OPEN_WAIT_MS);
      await tryAutoConfirm('direct');
      await waitDialogGone();
      await waitNodeRemoved(evt);
      if (id) markedIdSet.delete(id);
      pressEsc();
      await sleep(CLICK_DELAY_MS);
      return true;
    }

    listMenuItems();
    const del = findDeleteMenuItem();
    if(!del){ log('No Delete/Remove in menu (might not be your message, or thread is persistent).'); return false; }
    del.click();
    await sleep(DIALOG_OPEN_WAIT_MS);
    await tryAutoConfirm('menu');
    await waitDialogGone();
    await waitNodeRemoved(evt);
    if (id) markedIdSet.delete(id);
    pressEsc();
    await sleep(CLICK_DELAY_MS);
    return true;
  }

  // ============================ Buttons ============================
  btnSave.addEventListener('click', ()=>{
    const name = (inpUser.value || '').trim();
    localStorage.setItem(LS_KEY_USERNAME, name);
    log(name ? `Saved username: ${name}` : 'Username cleared.');
  });

  btnPick.addEventListener('click', async ()=>{
    const picked = await pickElementOnce();
    if(!picked){ log('Pick cancelled.'); return; }
    selectedContainer = bubbleToTimelineContainer(picked) || picked;
    outline(selectedContainer);
    attachMarkHandlers(selectedContainer);
    ensureMarksFromIdSet();
    log(`Picked container. ~${countEvents(selectedContainer)} timeline events.`);
  });

  btnParent.addEventListener('click', ()=>{
    if(!selectedContainer){ log('Pick a container first.'); return; }
    const parent = selectedContainer.parentNode instanceof ShadowRoot ? selectedContainer.parentNode.host : selectedContainer.parentElement;
    if(!parent){ log('No parent to select.'); return; }
    selectedContainer = bubbleToTimelineContainer(parent) || parent;
    outline(selectedContainer);
    attachMarkHandlers(selectedContainer);
    ensureMarksFromIdSet();
    log(`Selected parent. ~${countEvents(selectedContainer)} timeline events.`);
  });

  btnAutoYou.addEventListener('click', ()=> { autoMarkYouSaid(); ensureMarksFromIdSet(); });
  btnAutoUser.addEventListener('click', ()=>{
    const name = (inpUser.value || '').trim();
    autoMarkByUsername(name);
    ensureMarksFromIdSet();
  });

  btnMark.addEventListener('click', ()=>{
    if(!selectedContainer){ log('Pick a container first.'); return; }
    markMode = !markMode;
    btnMark.textContent = markMode ? 'Mark Mode (ON)' : 'Toggle Mark Mode';
    if(markMode){ attachMarkHandlers(selectedContainer); log('Mark Mode ON: click bubbles to toggle.'); }
    else { log('Mark Mode OFF.'); }
  });

  btnClear.addEventListener('click', ()=>{
    if(!selectedContainer){ log('Pick a container first.'); return; }
    for(const evt of getEvents(selectedContainer)) setMarked(evt,false);
    markedIdSet.clear();
    log('Cleared marks.');
  });

  btnDelOne.addEventListener('click', async ()=>{
    if(!selectedContainer){ log('Pick a container first.'); return; }
    ensureMarksFromIdSet();
    const targets = getEvents(selectedContainer).filter(e=>e.dataset.rcMarked==='true');
    if(!targets.length){ log('No marked messages. Use Auto-Mark or Mark Mode.'); return; }
    const t = targets[0];
    const ok = await deleteEvent(t);
    if(ok) { setMarked(t,false); log('Deleted one marked.'); } else { log('Delete failed for that bubble.'); }
  });

  btnDelBatch.addEventListener('click', async ()=>{
    if(!selectedContainer){ log('Pick a container first.'); return; }
    let success=0, fail=0;
    for (let i=0; i<BATCH_SIZE_DEFAULT; i++){
      ensureMarksFromIdSet();
      const targets = getEvents(selectedContainer).filter(e=>e.dataset.rcMarked==='true');
      if (!targets.length) break;
      const t = targets[0];
      try{
        const r = await deleteEvent(t);
        if (r){ setMarked(t,false); success++; } else { fail++; }
      }catch(e){
        log('Delete error: ' + (e && e.message ? e.message : e));
        fail++;
      }
    }
    log(`Batch done. Success: ${success}, Fail: ${fail}.`);
  });

  btnMenu.addEventListener('click', ()=>{
    MENU_LOG = !MENU_LOG;
    log('Menu Log ' + (MENU_LOG ? 'ENABLED' : 'DISABLED'));
  });

  btnDump.addEventListener('click', ()=>{
    ensureMarksFromIdSet();
    const events = selectedContainer ? getEvents(selectedContainer).slice(0,8) : [];
    const info = {
      url: location.href,
      hasSelectedContainer: !!selectedContainer,
      approxEventCount: selectedContainer ? countEvents(selectedContainer) : 0,
      sampleIds: events.map(e => getEventId(e)),
      sampleAria: events.map(e => {
        const m = localQuery(e, '.room-message[aria-label]');
        return m ? m.getAttribute('aria-label') : null;
      }),
      sampleAuthors: events.map(e => {
        const n = localQuery(e, '.user-name, [class*="user-name"], [data-testid="message-author"]');
        return n ? (n.innerText || n.textContent || '').trim() : null;
      })
    };
    log('DEBUG:\n' + JSON.stringify(info,null,2));
  });

  // ======================= Continuous Sweep =======================
  let sweepRunning = false;
  function setStatus(txt){ statusEl.textContent = txt || ''; }

  async function markVisiblePass(){
    const uname = (inpUser.value || '').trim();
    let n=0;
    if (uname){ n += autoMarkByUsername(uname); }
    n += autoMarkYouSaid(); // also try aria variant
    ensureMarksFromIdSet();
    return n;
  }
  async function deleteSomeMarked(maxCount=SWEEP_BATCH_SIZE){
    let success=0, fail=0;
    for (let i=0; i<maxCount; i++){
      ensureMarksFromIdSet();
      const targets = getEvents(selectedContainer).filter(e=>e.dataset.rcMarked==='true');
      if (!targets.length) break;
      const t = targets[0];
      try{
        const r = await deleteEvent(t);
        if (r){ setMarked(t,false); success++; } else { fail++; }
      }catch(e){
        log('Delete error: ' + (e && e.message ? e.message : e));
        fail++;
      }
    }
    return {success, fail};
  }
  async function sweepLoop(){
    if (!selectedContainer){ log('Pick a container first.'); setStatus('Pick container first'); return; }
    if (sweepRunning) return;
    sweepRunning = true;
    setStatus('Sweeping…');
    log('Continuous Sweep started.');

    let topStalls = 0;
    let totalDeleted = 0;

    while (sweepRunning){
      attachMarkHandlers(selectedContainer);
      ensureMarksFromIdSet();

      const newlyMarked = await markVisiblePass();
      if (newlyMarked > 0){
        const {success, fail} = await deleteSomeMarked(SWEEP_BATCH_SIZE);
        totalDeleted += success;
        log(`Sweep cycle: deleted ${success}, failed ${fail}, total so far ${totalDeleted}.`);
        topStalls = 0;
        await sleep(SWEEP_IDLE_PAUSE_MS);
        continue;
      }

      // No marked visible — scroll up to load more
      const scrollEl = getScrollable();
      if (!scrollEl){ log('No scrollable element found; stopping sweep.'); break; }
      const wasAtTop = atTop(scrollEl);
      await nudgeUp(scrollEl);
      attachMarkHandlers(selectedContainer);
      ensureMarksFromIdSet();

      const afterScrollMarked = await markVisiblePass();
      if (afterScrollMarked > 0){ topStalls = 0; continue; }

      if (wasAtTop && atTop(scrollEl)){
        topStalls++;
        log(`No more messages above (top stall ${topStalls}/${TOP_STALL_MAX}).`);
        if (topStalls >= TOP_STALL_MAX){
          log('Reached top with no more deletable messages. Stopping sweep.');
          break;
        }
      }
      await sleep(SWEEP_IDLE_PAUSE_MS);
    }

    sweepRunning = false;
    setStatus('');
    log('Continuous Sweep stopped.');
  }

  btnSweepStart.addEventListener('click', ()=>{ if (!sweepRunning) sweepLoop(); });
  btnSweepStop.addEventListener('click', ()=>{ sweepRunning = false; setStatus('Stopping…'); });

  // =================== SPA Navigation & Reapply ===================
  let lastUrl = location.href;
  new MutationObserver(()=>{
    if(location.href!==lastUrl){
      lastUrl=location.href; selectedContainer=null; log('URL changed — pick container again.');
      sweepRunning = false; setStatus('');
    }
  }).observe(document,{subtree:true, childList:true});

  const reapplyObserver = new MutationObserver(()=>{
    if (!selectedContainer) return;
    ensureMarksFromIdSet();
  });
  reapplyObserver.observe(document.body, {subtree:true, childList:true});

  log('Loaded v3.0.2. Continuous Sweep available — Start/Stop to auto-scroll up and remove your messages.');
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址