Production-ready Reddit chat cleanup with selective delete, sweep, purge, hide, and detailed logs
// ==UserScript==
// @name Reddit Chat Delete Suite
// @namespace http://tampermonkey.net/
// @version v3.3.0
// @description Production-ready Reddit chat cleanup with selective delete, sweep, purge, hide, and detailed logs
// @author Amrit
// @match https://www.reddit.com/chat*
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// ========================= CONFIGURATION =========================
const CONFIG = {
// Timings
INIT_DELAY_MS: 500,
BIND_TIMEOUT_MS: 4000,
BIND_POLL_MS: 150,
URL_WATCH_INTERVAL_MS: 750,
CHAT_CONTAINER_WAIT_MS: 5000,
// Deletion / Menus
MENU_WAIT_MS: 180,
DIALOG_WAIT_MS: 120,
DELETE_POST_CONFIRM_MS: 250,
// Sweep
SWEEP_BATCH: 10,
SWEEP_CYCLE_WAIT_MS: 500,
SCROLL_STEP_PX: 1400,
SCROLL_SETTLE_MS: 400,
TOP_STALL_MAX: 4,
// Hide Chat
HIDE_CHAT_DIALOG_TIMEOUT_MS: 6000,
DIALOG_POLL_INTERVAL_MS: 50,
// UI
LOG_MAX_LINES: 500,
};
// ========================= SETTINGS =========================
const DEFAULT_SETTINGS = {
showLog: true,
hideAfterDeletion: false,
autoMinimizeAfterDeletion: false,
deletionDelayMs: 250,
maxDeletionsPerMinute: 60,
emptyChatLoadWaitSec: 4,
markStyle: "modern",
username: "",
processModMails: false,
};
const Settings = {
key: "rcd_settings_ui_scaffold_v1",
current: { ...DEFAULT_SETTINGS },
load() {
try {
const raw = localStorage.getItem(this.key);
if (!raw) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULT_SETTINGS };
}
},
save() {
try {
localStorage.setItem(this.key, JSON.stringify(this.current));
} catch {}
},
init() {
this.current = this.load();
},
get(k) {
return this.current[k];
},
set(k, v) {
this.current[k] = v;
this.save();
},
};
// ========================= STATE =========================
const state = {
currentTab: "chat",
lastUrl: "",
isBinding: false,
markMode: false,
markedCount: 0,
totalDeleted: 0,
isBusy: false,
sweepActive: false,
cancelSweep: false,
cancelDelete: false,
cancelChatList: false,
processedReplyParents: new Set(),
// --- NEW: progress tracking ---
deleteProgress: { current: 0, total: 0 }, // current chat deletion progress
chatListProgress: { current: 0, total: 0 }, // chat list processing progress
chatsProcessed: 0, // session total chats fully processed
containers: {
main: null,
reply: null,
chatList: null,
settings: null,
},
};
// ========================= UTILITIES =========================
const Utils = {
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
deepNodes: function* (root) {
if (!root) return;
yield root;
try {
if (root instanceof Element && root.shadowRoot) {
yield root.shadowRoot;
yield* this.deepNodes(root.shadowRoot);
}
} catch (_) {}
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* this.deepNodes(el.shadowRoot);
}
if (el.tagName === "IFRAME") {
try {
if (el.contentDocument) {
yield el.contentDocument;
yield* this.deepNodes(el.contentDocument);
}
} catch (_) {}
}
}
},
deepQueryAll: function (sel, scope = document) {
const out = [];
for (const n of this.deepNodes(scope)) {
if (n.querySelectorAll) {
try {
out.push(...n.querySelectorAll(sel));
} catch (_) {}
}
}
return Array.from(new Set(out));
},
localQuery: function (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;
},
isVisible: function (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
);
},
findEventFromClick(e) {
const path = typeof e.composedPath === "function" ? e.composedPath() : [];
for (const node of path) {
if (node && node.tagName === "RS-TIMELINE-EVENT") return node;
}
const target = e.target;
if (target && target.closest) {
return target.closest("rs-timeline-event");
}
return null;
},
};
// ========================= THROTTLE =========================
const Throttle = {
windowMs: 60_000,
timestamps: [],
prune(now = Date.now()) {
this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs);
},
async waitForSlot() {
const max = parseInt(Settings.get("maxDeletionsPerMinute"), 10) || 0;
if (max <= 0) return;
const now = Date.now();
this.prune(now);
if (this.timestamps.length < max) {
this.timestamps.push(now);
return;
}
const oldest = this.timestamps[0];
const waitMs = Math.max(0, this.windowMs - (now - oldest) + 25);
if (waitMs > 250) {
UI.log(
`⏳ Rate limit: waiting ${Math.ceil(waitMs / 1000)}s`,
"warning",
);
}
await Utils.sleep(waitMs);
return this.waitForSlot();
},
async delayAfterDelete() {
const delay = parseInt(Settings.get("deletionDelayMs"), 10) || 0;
if (delay > 0) await Utils.sleep(delay);
},
reset() {
this.timestamps = [];
},
};
// ========================= CONTAINER DETECTION =========================
const Containers = {
isInReplyPane(el) {
let cur = el;
while (cur) {
if (cur.tagName === "RS-THREAD-TIMELINE") return true;
cur =
cur.parentNode instanceof ShadowRoot
? cur.parentNode.host
: cur.parentElement;
}
return false;
},
findReplyPane() {
const panes = Utils.deepQueryAll("rs-thread-timeline");
return panes.find(Utils.isVisible) || null;
},
findChatListContainer() {
const containers = Utils.deepQueryAll("rs-roving-focus-wrapper");
return containers.find(Utils.isVisible) || null;
},
findSettingsContainer() {
const settings = Utils.deepQueryAll("rs-room-settings");
return settings.find(Utils.isVisible) || null;
},
countEvents(scope) {
return Utils.deepQueryAll("rs-timeline-event", scope).length;
},
findBestContainerFromEvent(evt) {
let cur = evt;
let best = null;
let bestCount = 0;
for (let i = 0; i < 12 && cur; i++) {
const cnt = this.countEvents(cur);
if (cnt > bestCount) {
best = cur;
bestCount = cnt;
}
cur =
cur.parentNode instanceof ShadowRoot
? cur.parentNode.host
: cur.parentElement;
}
return best || evt;
},
findMainContainer() {
// Strategy 1: Look for timeline events (exists when chat has messages)
const events = Utils.deepQueryAll("rs-timeline-event");
if (events.length > 0) {
const mainEvents = events.filter((e) => !this.isInReplyPane(e));
if (mainEvents.length > 0) {
const evt = mainEvents.find(Utils.isVisible) || mainEvents[0];
return this.findBestContainerFromEvent(evt);
}
}
// Strategy 2: Look for the virtual scroll container (empty chat fallback)
const virtualScrolls = Utils.deepQueryAll("rs-virtual-scroll-dynamic");
if (virtualScrolls.length > 0) {
// Try visible first
const visible = virtualScrolls.find(Utils.isVisible);
if (visible) return visible;
// But return the first one even if not strictly visible
return virtualScrolls[0];
}
// Strategy 3: Look for rs-room-messages container
const roomMessages = Utils.deepQueryAll("rs-room-messages");
if (roomMessages.length > 0) {
const visible = roomMessages.find(Utils.isVisible);
if (visible) return visible;
return roomMessages[0];
}
// Strategy 4: Look for rs-room container itself
const rooms = Utils.deepQueryAll("rs-room");
if (rooms.length > 0) {
const visible = rooms.find(Utils.isVisible);
if (visible) return visible;
return rooms[0];
}
// Strategy 5: Last resort - look for ANY main/article element
const generics = Utils.deepQueryAll("main, article, [role='main']");
if (generics.length > 0) {
const visible = generics.find(Utils.isVisible);
if (visible) return visible;
return generics[0];
}
return null;
},
refresh() {
state.containers.reply = this.findReplyPane();
state.containers.chatList = this.findChatListContainer();
state.containers.settings = this.findSettingsContainer();
},
async bindMainContainer({ silent = false } = {}) {
if (state.isBinding) return null;
state.isBinding = true;
// Get caller info for debugging
const caller = new Error().stack?.split("\n")[2]?.trim() || "unknown";
const callerName = caller.includes("at ")
? caller.split("at ")[1].split(" ")[0]
: "unknown";
try {
const start = Date.now();
let container = null;
while (Date.now() - start < CONFIG.BIND_TIMEOUT_MS) {
container = this.findMainContainer();
if (container) break;
await Utils.sleep(CONFIG.BIND_POLL_MS);
}
// Log binding attempt with caller info
if (container) {
const tag = container.tagName || container.nodeName || "?";
const eventCount = Utils.deepQueryAll(
"rs-timeline-event",
container,
).length;
const isConnected = container.isConnected ? "✓" : "✗";
UI.log(
`🔗 [BIND] caller=${callerName} container=${tag} events=${eventCount} connected=${isConnected}`,
eventCount === 0 ? "warning" : "info",
);
} else {
UI.log(
`🔗 [BIND] caller=${callerName} FAILED no container found`,
"error",
);
}
state.containers.main = container;
this.refresh();
state.markedCount = 0;
Selection.markedIds.clear();
if (container) {
Selection.syncFromIds(container);
}
if (state.markMode) {
Selection.attach(state.containers.main);
if (state.containers.reply) Selection.attach(state.containers.reply);
}
UI.updateMarkedCount();
UI.updateStatsDisplay();
UI.updateControls();
if (!silent) {
if (container) {
UI.log("✓ Main chat container detected", "success");
} else {
UI.log("⚠️ Could not detect main chat container", "warning");
}
const open = [];
if (state.containers.main) open.push("main");
if (state.containers.reply) open.push("reply");
if (state.containers.chatList) open.push("chat list");
if (state.containers.settings) open.push("settings");
UI.log(
open.length
? `Open containers: ${open.join(", ")}`
: "No containers detected",
);
}
return container;
} finally {
state.isBinding = false;
}
},
async bindMainContainerWithPolling({
maxWaitMs = 15000,
pollIntervalMs = 200,
silent = false,
} = {}) {
if (state.isBinding) return null;
state.isBinding = true;
const caller = new Error().stack?.split("\n")[2]?.trim() || "unknown";
const callerName = caller.includes("at ")
? caller.split("at ")[1].split(" ")[0]
: "unknown";
try {
const start = Date.now();
let bestContainer = null;
let bestEventCount = 0;
// Poll for container with events, up to maxWaitMs
while (Date.now() - start < maxWaitMs) {
const container = this.findMainContainer();
if (container) {
const eventCount = Utils.deepQueryAll(
"rs-timeline-event",
container,
).length;
// If we found events, bind immediately
if (eventCount > 0) {
bestContainer = container;
bestEventCount = eventCount;
UI.log(
`⏱️ [POLL] Found container with ${eventCount} events after ${Date.now() - start}ms`,
"success",
);
break;
}
// Track best container even if empty
if (eventCount > bestEventCount) {
bestContainer = container;
bestEventCount = eventCount;
}
}
await Utils.sleep(pollIntervalMs);
}
// Log binding attempt with caller info
if (bestContainer) {
const tag = bestContainer.tagName || bestContainer.nodeName || "?";
const isConnected = bestContainer.isConnected ? "✓" : "✗";
const elapsed = Date.now() - start;
UI.log(
`🔗 [BIND] caller=${callerName} container=${tag} events=${bestEventCount} connected=${isConnected} elapsed=${elapsed}ms`,
bestEventCount === 0 ? "warning" : "success",
);
} else {
UI.log(
`🔗 [BIND] caller=${callerName} FAILED no container found after ${Date.now() - start}ms`,
"error",
);
}
state.containers.main = bestContainer;
this.refresh();
state.markedCount = 0;
Selection.markedIds.clear();
if (bestContainer) {
Selection.syncFromIds(bestContainer);
}
if (state.markMode) {
Selection.attach(state.containers.main);
if (state.containers.reply) Selection.attach(state.containers.reply);
}
UI.updateMarkedCount();
UI.updateStatsDisplay();
UI.updateControls();
if (!silent) {
if (bestContainer) {
UI.log("✓ Main chat container detected", "success");
} else {
UI.log("⚠️ Could not detect main chat container", "warning");
}
const open = [];
if (state.containers.main) open.push("main");
if (state.containers.reply) open.push("reply");
if (state.containers.chatList) open.push("chat list");
if (state.containers.settings) open.push("settings");
UI.log(
open.length
? `Open containers: ${open.join(", ")}`
: "No containers detected",
);
}
return bestContainer;
} finally {
state.isBinding = false;
}
},
};
// ========================= DOM HELPERS =========================
const DOM = {
getEvents(scope) {
return Utils.deepQueryAll("rs-timeline-event", scope).filter(
Utils.isVisible,
);
},
getReplyThreadEvents(replyPane) {
if (!replyPane) return [];
const vscroll = Utils.localQuery(replyPane, "rs-virtual-scroll-dynamic");
if (vscroll) {
const events = Utils.deepQueryAll("rs-timeline-event", vscroll).filter(
Utils.isVisible,
);
if (events.length) return events;
}
return this.getEvents(replyPane).filter((evt) =>
Containers.isInReplyPane(evt),
);
},
getEventId(evt) {
const id = evt.getAttribute && evt.getAttribute("data-id");
if (id) return id;
const child = Utils.localQuery(evt, "[data-id]");
return child ? child.getAttribute("data-id") : null;
},
hasReplyThread(evt) {
const replyBtn = Utils.localQuery(
evt,
["button.replies", 'button[class*="replies"]'].join(","),
);
if (replyBtn && Utils.isVisible(replyBtn)) return replyBtn;
return null;
},
findReplyPaneCloseButton() {
const closeButtons = Utils.deepQueryAll(
'button[aria-label*="Close thread" i], rs-thread header button',
);
for (const btn of closeButtons) {
if (
Utils.isVisible(btn) &&
(btn.getAttribute("aria-label") || "").toLowerCase().includes("close")
) {
return btn;
}
}
return null;
},
isOwnMessage(evt, username) {
if (!username) return false;
const uname = String(username).trim().toLowerCase();
const m = Utils.localQuery(evt, ".room-message[aria-label]");
const aria = m ? m.getAttribute("aria-label") || "" : "";
return (
!!uname && new RegExp(`^${uname}\\s+said\\b`, "i").test(aria.trim())
);
},
getMessageNode(evt) {
return Utils.localQuery(evt, ".room-message") || null;
},
ensureMarkStyles(evt) {
const root = evt?.shadowRoot;
if (!root) return;
if (root.getElementById("rcd-mark-style")) return;
const style = document.createElement("style");
style.id = "rcd-mark-style";
style.textContent = `
:host(.rc-marked) {
box-shadow: inset 3px 0 0 #22c55e;
}
.rc-marked-msg.modern {
background: rgba(34,197,94,0.07);
}
.rc-marked-msg.border {
box-shadow: 0 0 0 1px #22c55e inset;
outline: 1px solid #22c55e;
}
`;
root.appendChild(style);
},
};
// ========================= SELECTION =========================
const Selection = {
markedIds: new Set(),
boundContainers: new WeakSet(),
canBeMarked(evt, username) {
const isOwn = DOM.isOwnMessage(evt, username);
const hasReplies = DOM.hasReplyThread(evt);
if (!isOwn && hasReplies) {
const id = DOM.getEventId(evt);
if (id && state.processedReplyParents.has(id)) return false;
}
if (Containers.isInReplyPane(evt)) {
return isOwn;
}
return isOwn || hasReplies;
},
setMark(evt, marked) {
if (!evt) return;
const wasMarked = evt.dataset?.rcMarked === "true";
if (marked && wasMarked) return;
if (!marked && !wasMarked) return;
if (marked) {
evt.dataset.rcMarked = "true";
evt.classList.add("rc-marked");
DOM.ensureMarkStyles(evt);
const msg = DOM.getMessageNode(evt);
if (msg) {
msg.classList.add("rc-marked-msg");
msg.classList.remove("modern", "border");
msg.classList.add(Settings.get("markStyle") || "modern");
}
const id = DOM.getEventId(evt);
if (id) this.markedIds.add(id);
state.markedCount++;
} else {
delete evt.dataset.rcMarked;
evt.classList.remove("rc-marked");
const msg = DOM.getMessageNode(evt);
if (msg) msg.classList.remove("rc-marked-msg", "modern", "border");
const id = DOM.getEventId(evt);
if (id) this.markedIds.delete(id);
state.markedCount = Math.max(0, state.markedCount - 1);
}
},
toggleMark(evt) {
const on = !(evt.dataset.rcMarked === "true");
this.setMark(evt, on);
},
attach(container) {
if (!container || this.boundContainers.has(container)) return;
this.boundContainers.add(container);
container.addEventListener(
"click",
(e) => {
if (!state.markMode) return;
if (!e.isTrusted) return;
if (!container.contains(e.target)) return;
const panel = document.getElementById("rcd-panel");
if (panel && panel.contains(e.target)) return;
const evt = Utils.findEventFromClick(e);
if (!evt) return;
const username = Settings.get("username");
const isOwn = DOM.isOwnMessage(evt, username);
const hasReplies = DOM.hasReplyThread(evt);
if (!isOwn && !hasReplies) {
UI.log(
"⚠️ Cannot mark: no replies on other user's message",
"warning",
);
return;
}
if (!this.canBeMarked(evt, username)) {
UI.log("⚠️ This message cannot be marked", "warning");
return;
}
e.preventDefault();
e.stopImmediatePropagation();
this.toggleMark(evt);
UI.updateMarkedCount();
UI.updateStatsDisplay();
UI.updateControls();
},
{ capture: true },
);
},
syncFromIds(container) {
if (!container || !this.markedIds.size) return;
const events = DOM.getEvents(container);
for (const evt of events) {
const id = DOM.getEventId(evt);
if (id && this.markedIds.has(id) && evt.dataset.rcMarked !== "true") {
this.setMark(evt, true);
}
}
},
clearAll(container) {
if (!container) return;
const events = DOM.getEvents(container);
for (const evt of events) this.setMark(evt, false);
this.markedIds.clear();
state.markedCount = 0;
},
getMarked(container) {
if (!container) return [];
return DOM.getEvents(container).filter(
(e) => e.dataset.rcMarked === "true",
);
},
autoSelectOwn(container, username) {
if (!container) {
UI.log("🧭 [AUTOSELECT] container=null, skipping", "warning");
return 0;
}
let count = 0;
const events = DOM.getEvents(container);
const tag = container.tagName || container.nodeName || "?";
const isConnected = container.isConnected ? "✓" : "✗";
for (const evt of events) {
if (this.canBeMarked(evt, username)) {
this.setMark(evt, true);
count++;
}
}
// Log only if we found events but couldn't mark any (suspicious)
if (events.length > 0 && count === 0) {
UI.log(
`🧭 [AUTOSELECT] WARNING: container=${tag} connected=${isConnected} events=${events.length} marked=${count}`,
"warning",
);
}
return count;
},
autoSelectBoth(username) {
let total = 0;
if (state.containers.main) {
total += this.autoSelectOwn(state.containers.main, username);
}
if (state.containers.reply) {
total += this.autoSelectOwn(state.containers.reply, username);
}
return total;
},
};
// ========================= SCROLL =========================
const Scroll = {
getScrollable(container) {
let cur = container;
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;
}
return container || document.scrollingElement || document.documentElement;
},
isAtTop(scrollEl) {
return !scrollEl ? true : scrollEl.scrollTop <= 2;
},
async scrollUp(scrollEl) {
if (!scrollEl) return;
if (scrollEl.scrollTop > 0) {
scrollEl.scrollTop = Math.max(
0,
scrollEl.scrollTop - CONFIG.SCROLL_STEP_PX,
);
} else {
scrollEl.scrollTop = 1;
await Utils.sleep(30);
scrollEl.scrollTop = 0;
}
await Utils.sleep(CONFIG.SCROLL_SETTLE_MS);
},
};
// ========================= DELETION (SELECTED ONLY) =========================
const Deletion = {
async openActions(evt) {
for (let i = 0; i < 5; i++) {
evt.scrollIntoView({ block: "center" });
evt.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await Utils.sleep(80);
const trash = Utils.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 && Utils.isVisible(trash)) return { directDelete: trash };
const more = Utils.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 && Utils.isVisible(more)) {
more.click();
await Utils.sleep(180);
return { openedMenu: true };
}
}
return {};
},
findDeleteMenuItem() {
const items = Utils.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;
},
findDeleteDialog() {
const rsDlg = Utils.deepQueryAll(
"rs-delete-message-modal rpl-dialog",
).find(Utils.isVisible);
if (rsDlg) return rsDlg;
const candidates = Utils.deepQueryAll(
'rpl-dialog, [role="dialog"], div',
).filter(Utils.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
);
},
confirmDeleteDialog(dlg) {
const btns = Utils.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 true;
}
const fallback = btns.find((b) =>
/delete|confirm|yes/i.test((b.innerText || b.textContent || "").trim()),
);
if (fallback) {
fallback.click();
return true;
}
return false;
},
async deleteMessage(evt) {
return this.deleteMessageDirect(evt);
},
async deleteMessageDirect(evt) {
await Throttle.waitForSlot();
const open = await this.openActions(evt);
if (open.directDelete) {
open.directDelete.click();
await Utils.sleep(CONFIG.DIALOG_WAIT_MS);
} else {
const deleteItem = this.findDeleteMenuItem();
if (!deleteItem) return false;
deleteItem.click();
await Utils.sleep(CONFIG.DIALOG_WAIT_MS);
}
const dlg = this.findDeleteDialog();
if (dlg) this.confirmDeleteDialog(dlg);
await Utils.sleep(CONFIG.DELETE_POST_CONFIRM_MS);
await Throttle.delayAfterDelete();
return true;
},
async deleteRepliesInPane(replyPane, username) {
const allEvents = DOM.getReplyThreadEvents(replyPane);
const ownMsgs = allEvents.filter((evt) =>
DOM.isOwnMessage(evt, username),
);
if (!ownMsgs.length) return { deleted: 0, remaining: 0 };
let deleted = 0;
for (const evt of ownMsgs) {
const ok = await this.deleteMessageDirect(evt);
if (ok) deleted++;
}
await Utils.sleep(200);
const stillThere = DOM.getReplyThreadEvents(replyPane).filter((evt) =>
DOM.isOwnMessage(evt, username),
);
return { deleted, remaining: stillThere.length };
},
async handleReplyPaneIfNeeded(evt, username) {
const replyBtn = DOM.hasReplyThread(evt);
if (!replyBtn) return { hadReplies: false, fullyCleaned: true };
replyBtn.click();
await Utils.sleep(CONFIG.MENU_WAIT_MS);
const replyPane = Containers.findReplyPane();
if (!replyPane) {
UI.log("⚠️ Could not find reply pane", "warning");
return { hadReplies: true, fullyCleaned: false };
}
UI.log("📎 Processing replies...");
const result = await this.deleteRepliesInPane(replyPane, username);
if (result.deleted > 0) {
UI.log(`✅ Deleted ${result.deleted} replies`);
}
if (result.remaining > 0) {
UI.log(
`⚠️ ${result.remaining} replies remaining after delete`,
"warning",
);
}
const closeBtn = DOM.findReplyPaneCloseButton();
if (closeBtn) {
closeBtn.click();
await Utils.sleep(CONFIG.MENU_WAIT_MS);
}
return { hadReplies: true, fullyCleaned: result.remaining === 0 };
},
async deleteOwnMessage(evt) {
const ok = await this.deleteMessageDirect(evt);
if (!ok) return false;
await Utils.sleep(120);
return !evt.isConnected;
},
async deleteSelectedBatch(container, batch) {
if (!container || !batch.length) return { success: 0, failed: 0 };
let success = 0;
let failed = 0;
const username = Settings.get("username");
for (const evt of batch) {
if (state.cancelDelete) break;
try {
const replyResult = await this.handleReplyPaneIfNeeded(evt, username);
const isOwn = DOM.isOwnMessage(evt, username);
let ok = false;
if (isOwn) {
ok = await this.deleteOwnMessage(evt);
} else if (replyResult.hadReplies && replyResult.fullyCleaned) {
const id = DOM.getEventId(evt);
if (id) state.processedReplyParents.add(id);
ok = true;
}
if (ok) {
success++;
Selection.setMark(evt, false);
state.totalDeleted++;
// Update delete progress
state.deleteProgress.current++;
UI.updateStatsDisplay();
} else {
failed++;
}
} catch {
failed++;
}
}
return { success, failed };
},
async deleteSelected(container) {
if (!container) {
UI.log("❌ Select a chat first", "error");
return;
}
const marked = Selection.getMarked(container);
if (!marked.length) {
UI.log("⚠️ No selected messages", "warning");
return;
}
// Reset and initialise progress for this run
state.deleteProgress = { current: 0, total: marked.length };
UI.log(`🗑️ Deleting ${marked.length} selected...`);
state.isBusy = true;
state.cancelDelete = false;
UI.setHeaderStatus("running", "Deleting");
UI.updateControls();
UI.updateStatsDisplay();
const { success, failed } = await this.deleteSelectedBatch(
container,
marked,
);
state.isBusy = false;
state.cancelDelete = false;
// Clear progress after run
state.deleteProgress = { current: 0, total: 0 };
UI.setHeaderStatus("idle");
UI.updateControls();
UI.updateMarkedCount();
UI.updateStatsDisplay();
UI.log(`✅ Deleted ${success}, Failed ${failed}`);
if (!state.cancelDelete && Settings.get("hideAfterDeletion")) {
await HideChat.execute();
}
if (!state.cancelDelete && Settings.get("autoMinimizeAfterDeletion")) {
UI.panel?.classList.add("minimized");
const btn = document.getElementById("rcd-minimize");
if (btn) btn.textContent = "+";
}
},
};
// ========================= SWEEP =========================
const Sweep = {
describeContainer(container) {
if (!container) return "null";
const tag = container.tagName || container.nodeName || "node";
const id = container.id ? `#${container.id}` : "";
const cls = container.className
? `.${String(container.className).trim().replace(/\s+/g, ".")}`
: "";
return `${String(tag).toLowerCase()}${id}${cls}`;
},
getOwnVisibleCount(container, username) {
if (!container) return 0;
const events = DOM.getEvents(container);
let own = 0;
for (const evt of events) {
if (DOM.isOwnMessage(evt, username)) own++;
}
return own;
},
async run() {
if (!state.containers.main) {
UI.log("❌ Select a chat first", "error");
return;
}
if (state.sweepActive) return;
const username = Settings.get("username");
if (!username) {
UI.log("❌ Please save username first", "error");
return;
}
// Force fresh container binding to avoid stale references
await Containers.bindMainContainer({ silent: true });
if (!state.containers.main) {
UI.log("❌ Could not bind to chat container", "error");
return;
}
// Validate container health
const container = state.containers.main;
const isConnected = container.isConnected;
const eventCount = DOM.getEvents(container).length;
const tag = container.tagName || container.nodeName || "?";
if (!isConnected) {
UI.log(
`⚠️ Bound container is detached from DOM (${tag}), attempting rebind...`,
"warning",
);
await Containers.bindMainContainer({ silent: true });
if (!state.containers.main) {
UI.log("❌ Rebind failed, aborting sweep", "error");
return;
}
}
UI.log(
`✓ Sweep starting with container=${tag} events=${eventCount} connected=${isConnected}`,
"success",
);
// Reset progress for this sweep run
state.deleteProgress = { current: 0, total: 0 };
state.sweepActive = true;
state.isBusy = true;
state.cancelSweep = false;
state.cancelDelete = false;
UI.setHeaderStatus("running", "Running");
UI.updateControls();
UI.updateStatsDisplay();
UI.log("🔄 Auto sweep started");
UI.log(
`🧭 Sweep init: container=${this.describeContainer(state.containers.main)}`,
);
let topStalls = 0;
let cycle = 0;
try {
while (state.sweepActive && !state.cancelSweep) {
cycle++;
const main = state.containers.main;
const visibleBefore = DOM.getEvents(main).length;
const ownBefore = this.getOwnVisibleCount(main, username);
const markedBefore = Selection.getMarked(main).length;
UI.log(
`🧭 Sweep cycle ${cycle}: visible=${visibleBefore} own=${ownBefore} marked=${markedBefore} topStalls=${topStalls}`,
);
Selection.syncFromIds(main);
const newlyMarked = Selection.autoSelectOwn(main, username);
if (newlyMarked > 0) {
UI.log(`📋 Cycle ${cycle}: Found ${newlyMarked} messages`);
// Add newly discovered messages to the total
state.deleteProgress.total += newlyMarked;
UI.updateStatsDisplay();
const marked = Selection.getMarked(main);
const batch = marked.slice(0, CONFIG.SWEEP_BATCH);
const { success, failed } = await Deletion.deleteSelectedBatch(
main,
batch,
);
UI.log(`✅ Deleted ${success}, Failed ${failed}`);
UI.updateMarkedCount();
UI.updateStatsDisplay();
topStalls = 0;
await Utils.sleep(CONFIG.SWEEP_CYCLE_WAIT_MS);
continue;
}
const scrollEl = Scroll.getScrollable(main);
const scrollTag = this.describeContainer(scrollEl);
const beforeTop = scrollEl?.scrollTop ?? -1;
const wasAtTop = Scroll.isAtTop(scrollEl);
await Scroll.scrollUp(scrollEl);
const afterTop = scrollEl?.scrollTop ?? -1;
UI.log(
`🧭 Scroll probe: scroller=${scrollTag} topBefore=${beforeTop} topAfter=${afterTop} wasAtTop=${wasAtTop}`,
);
const afterScroll = Selection.autoSelectOwn(main, username);
if (afterScroll > 0) {
UI.log(`🧭 Post-scroll found ${afterScroll} markable messages`);
state.deleteProgress.total += afterScroll;
UI.updateStatsDisplay();
topStalls = 0;
continue;
}
const stillAtTop = Scroll.isAtTop(scrollEl);
const visibleAfter = DOM.getEvents(main).length;
const ownAfter = this.getOwnVisibleCount(main, username);
UI.log(
`🧭 Post-scroll scan: visible=${visibleAfter} own=${ownAfter} stillAtTop=${stillAtTop}`,
);
if (wasAtTop && stillAtTop) {
topStalls++;
UI.log(`⬆️ At top (${topStalls}/${CONFIG.TOP_STALL_MAX})`);
if (topStalls >= CONFIG.TOP_STALL_MAX) break;
}
await Utils.sleep(CONFIG.SWEEP_CYCLE_WAIT_MS);
}
} finally {
const wasStopped = !!state.cancelSweep;
state.sweepActive = false;
state.cancelSweep = false;
state.isBusy = false;
// Clear progress after sweep
state.deleteProgress = { current: 0, total: 0 };
if (wasStopped) {
UI.flashHeaderStatus("stopped", "Stopped");
} else {
UI.setHeaderStatus("idle");
}
UI.updateControls();
UI.updateStatsDisplay();
UI.log("🏁 Sweep completed");
if (!wasStopped && Settings.get("hideAfterDeletion")) {
await HideChat.execute();
}
if (!wasStopped && Settings.get("autoMinimizeAfterDeletion")) {
UI.panel?.classList.add("minimized");
const btn = document.getElementById("rcd-minimize");
if (btn) btn.textContent = "+";
}
}
},
stop() {
if (state.sweepActive) {
state.cancelSweep = true;
UI.setHeaderStatus("stopping");
UI.log("⏸️ Stopping sweep...");
}
},
};
// ========================= CHAT LIST =========================
const ChatList = {
markedChatIds: new Set(),
purgePendingRoomIds: new Set(),
purgeProcessingRoomId: null,
chatListMarkMode: false,
chatListProcessing: false,
chatListPurgeActive: false,
setListMarkModeUI(enabled) {
const btn = document.getElementById("rcd-chat-mark-mode");
if (!btn) return;
btn.textContent = enabled ? "🖱️ Mark Mode (ON)" : "🖱️ Mark Mode";
btn.style.background = enabled
? "linear-gradient(135deg, #10b981, #059669)"
: "";
},
beginListProcessing() {
const wasMarkMode = this.chatListMarkMode;
if (wasMarkMode) {
this.chatListMarkMode = false;
this.setListMarkModeUI(false);
}
return wasMarkMode;
},
endListProcessing(wasMarkMode) {
if (wasMarkMode) {
this.chatListMarkMode = true;
this.setListMarkModeUI(true);
}
},
findContainer() {
return Containers.findChatListContainer();
},
getChatItems(container) {
if (!container) return [];
return Utils.deepQueryAll("rs-rooms-nav-room", container).filter(
Utils.isVisible,
);
},
getChatRoomId(chatItem) {
return chatItem.getAttribute("room") || null;
},
refreshChatVisual(chatItem) {
if (!chatItem?.shadowRoot) return;
const innerLink = chatItem.shadowRoot.querySelector("div > a");
if (!innerLink) return;
const roomId = this.getChatRoomId(chatItem);
const manuallyMarked = chatItem.dataset?.rcdChatMarked === "true";
const purgePending = !!(roomId && this.purgePendingRoomIds.has(roomId));
const purgeProcessing = !!(
roomId &&
this.purgeProcessingRoomId &&
roomId === this.purgeProcessingRoomId
);
// Skip highlighting for MMCIs if processModMails is disabled
const isModmail = this.isModmailChat(chatItem);
const processModMails = Settings.get("processModMails");
if (isModmail && !processModMails) {
innerLink.style.boxShadow = "";
innerLink.style.outline = "";
innerLink.style.outlineOffset = "";
return;
}
const shouldHighlight = manuallyMarked || purgePending;
if (!shouldHighlight) {
innerLink.style.boxShadow = "";
innerLink.style.outline = "";
innerLink.style.outlineOffset = "";
return;
}
innerLink.style.boxShadow = "0 0 0 3px #22c55e inset";
innerLink.style.outline = purgeProcessing
? "3px solid #16a34a"
: "3px solid #22c55e";
innerLink.style.outlineOffset = "-3px";
},
syncPurgeHighlights(container) {
const items = this.getChatItems(container || this.findContainer());
for (const item of items) {
this.refreshChatVisual(item);
}
},
isModmailChat(chatItem) {
if (!chatItem?.shadowRoot) return false;
try {
// Strategy 1: Look for rs-channel-icon with channeltype="reddit_modmail"
const channelIcon = chatItem.shadowRoot.querySelector(
'rs-channel-icon[channeltype="reddit_modmail"]',
);
if (channelIcon) return true;
// Strategy 2: Look for MOD badge
const modBadge = chatItem.shadowRoot.querySelector(
".text-global-moderator",
);
if (modBadge) return true;
return false;
} catch {
return false;
}
},
getChatUsername(chatItem) {
try {
if (!chatItem.shadowRoot) return "Unknown";
const nameSpan = chatItem.shadowRoot.querySelector(
"div > a > div > div:nth-child(1) > div > span.room-name",
);
return nameSpan ? nameSpan.textContent.trim() : "Unknown";
} catch {
return "Unknown";
}
},
isCurrentlyActive(chatItem) {
return (
chatItem.hasAttribute("selected") &&
chatItem.getAttribute("tabindex") === "0"
);
},
markChat(chatItem, marked) {
if (!chatItem) return;
// Skip MMCIs if processModMails is disabled
if (marked && this.isModmailChat(chatItem)) {
const processModMails = Settings.get("processModMails");
if (!processModMails) {
return; // Silently skip without marking
}
}
const wasMarked = chatItem.dataset?.rcdChatMarked === "true";
if (marked && wasMarked) return;
if (!marked && !wasMarked) return;
if (marked) {
chatItem.dataset.rcdChatMarked = "true";
const roomId = this.getChatRoomId(chatItem);
if (roomId) this.markedChatIds.add(roomId);
} else {
delete chatItem.dataset.rcdChatMarked;
const roomId = this.getChatRoomId(chatItem);
if (roomId) this.markedChatIds.delete(roomId);
}
this.refreshChatVisual(chatItem);
},
toggleMarkChat(chatItem) {
const on = !(chatItem.dataset.rcdChatMarked === "true");
// Check if trying to select MMCI when disabled
if (on && this.isModmailChat(chatItem)) {
const processModMails = Settings.get("processModMails");
if (!processModMails) {
const username = this.getChatUsername(chatItem);
UI.log(
`⚠️ Cannot select mod mail chat "${username}" - enable "Process Mod Mails" in settings`,
"warning",
);
return;
}
}
this.markChat(chatItem, on);
},
attachChatClickHandlers(container) {
const items = this.getChatItems(container);
for (const item of items) {
this.refreshChatVisual(item);
if (item._rcdChatBound) continue;
item._rcdChatBound = true;
const clickHandler = (e) => {
if (!this.chatListMarkMode) return;
// Warn if clicking MMCI in skip mode
if (this.isModmailChat(item)) {
const processModMails = Settings.get("processModMails");
if (!processModMails) {
const username = this.getChatUsername(item);
UI.log(
`⚠️ Cannot select mod mail chat "${username}" - enable "Process Mod Mails" in settings`,
"warning",
);
return;
}
}
e.preventDefault();
e.stopImmediatePropagation();
this.toggleMarkChat(item);
UI.updateChatListCount();
UI.updateChatListControls();
};
item._rcdChatClickHandler = clickHandler;
item.addEventListener("click", clickHandler, { capture: true });
}
},
selectAllChats(container) {
const items = this.getChatItems(container);
const processModMails = Settings.get("processModMails");
let count = 0;
for (const item of items) {
// Skip MMCIs if processModMails is disabled
if (this.isModmailChat(item) && !processModMails) continue;
this.markChat(item, true);
count++;
}
return count;
},
clearAllChats(container) {
const items = this.getChatItems(container);
for (const item of items) {
this.markChat(item, false);
}
this.markedChatIds.clear();
},
getMarkedChats(container) {
if (!container) return [];
const items = this.getChatItems(container);
return items.filter((item) => item.dataset.rcdChatMarked === "true");
},
async navigateToChat(chatItem) {
if (!chatItem || !chatItem.shadowRoot) {
return { success: false, reason: "Invalid chat item" };
}
try {
const link = chatItem.shadowRoot.querySelector("div > a");
if (!link) return { success: false, reason: "Link not found" };
const roomId = this.getChatRoomId(chatItem) || "unknown";
const href = link.getAttribute("href") || link.href || "(no href attr)";
UI.log(`🔗 Navigate: room=${roomId} href=${href}`);
link.click();
await Utils.sleep(300);
const maxAttempts = 15;
for (let i = 0; i < maxAttempts; i++) {
if (this.isCurrentlyActive(chatItem)) {
UI.log(`✅ Active chat detected (attempt ${i + 1}/${maxAttempts})`);
const pollMs = 200;
const waitSec = Math.max(
0,
parseFloat(Settings.get("emptyChatLoadWaitSec")) || 0,
);
const messageAttempts = Math.max(
1,
Math.ceil((waitSec * 1000) / pollMs),
);
UI.log(`⏱️ Waiting up to ${waitSec}s for messages...`);
await Utils.sleep(pollMs);
let finalContainer = null;
let messageCount = 0;
for (let j = 0; j < messageAttempts; j++) {
const mainContainer = Containers.findMainContainer();
if (mainContainer) {
const count = Containers.countEvents(mainContainer);
finalContainer = mainContainer;
messageCount = count;
if (j === 0) {
UI.log(`🧾 Container found, messages=${count}`);
}
if (count > 0) {
UI.log(
`✅ Messages loaded (attempt ${j + 1}/${messageAttempts})`,
);
return { success: true, container: mainContainer };
}
}
await Utils.sleep(pollMs);
}
if (finalContainer) {
UI.log(
`⏳ No messages found after waiting, proceeding with empty chat`,
);
return { success: true, container: finalContainer };
}
return { success: false, reason: "No container found for chat" };
}
if (i === 0 || i === 4 || i === 9 || i === 14) {
UI.log(`⏳ Waiting for active chat (${i + 1}/${maxAttempts})`);
}
await Utils.sleep(200);
}
return { success: false, reason: "Chat did not become active" };
} catch (error) {
return { success: false, reason: error.message };
}
},
async processSingleChat(chatItem) {
const username = this.getChatUsername(chatItem);
const roomId = this.getChatRoomId(chatItem);
const isModmail = this.isModmailChat(chatItem);
UI.log(`📱 Processing chat with ${username}...`);
const nav = await this.navigateToChat(chatItem);
if (!nav.success) {
UI.log(`❌ Failed to open chat: ${nav.reason}`, "error");
return { success: false, username, roomId };
}
state.containers.main = nav.container;
await Containers.bindMainContainer({ silent: true });
// Handle Mod Mail chats specially
if (isModmail) {
UI.log(
`🔒 Mod mail chat detected - messages cannot be deleted`,
"info",
);
const hideAfterDeletion = Settings.get("hideAfterDeletion");
if (hideAfterDeletion) {
UI.log(`📬 Hiding mod mail chat only (no message deletion)...`);
await HideChat.executeForModmail();
UI.log(`✅ Mod mail chat hidden successfully`);
} else {
UI.log(
`⏭️ hideAfterDeletion disabled, skipping hide for mod mail`,
"info",
);
}
state.chatsProcessed++;
UI.updateStatsDisplay();
return { success: true, username, roomId };
}
// Normal chat processing
await Sweep.run();
state.chatsProcessed++;
UI.updateStatsDisplay();
UI.log(`✅ Completed processing ${username}`);
return { success: true, username, roomId };
},
async processSelectedChats(container) {
if (this.chatListProcessing || this.chatListPurgeActive) {
UI.log("⚠️ Chat list already running", "warning");
return;
}
const marked = this.getMarkedChats(container);
if (!marked.length) {
UI.log("⚠️ No chats selected", "warning");
return;
}
const wasMarkMode = this.chatListMarkMode;
UI.log(
`🧭 Chat list process: ${marked.length} selected, markMode=${wasMarkMode ? "ON" : "OFF"}`,
);
const restoreMarkMode = this.beginListProcessing();
// Initialise chat list progress
state.chatListProgress = { current: 0, total: marked.length };
this.chatListProcessing = true;
this.chatListPurgeActive = false;
state.cancelChatList = false;
UI.setHeaderStatus("running", "Processing");
UI.updateChatListControls();
UI.updateControls();
UI.updateStatsDisplay();
let completed = 0;
let failed = 0;
for (let i = 0; i < marked.length; i++) {
if (state.cancelChatList) break;
const item = marked[i];
const roomId = this.getChatRoomId(item) || "unknown";
const username = this.getChatUsername(item) || "Unknown";
UI.log(`\n--- Chat ${i + 1}/${marked.length} ---`);
UI.log(`➡️ Target: ${username} (room=${roomId})`);
const result = await this.processSingleChat(item);
state.chatListProgress.current++;
UI.updateStatsDisplay();
if (result.success) {
this.markChat(item, false);
completed++;
} else {
failed++;
}
await Utils.sleep(500);
}
this.chatListProcessing = false;
state.cancelChatList = false;
state.chatListProgress = { current: 0, total: 0 };
this.endListProcessing(restoreMarkMode);
UI.setHeaderStatus("idle");
UI.updateControls();
UI.updateStatsDisplay();
UI.log(`\n🏁 Batch complete: ${completed} processed, ${failed} failed`);
UI.updateChatListCount();
UI.updateChatListControls();
},
async hideSelectedChats(container) {
if (this.chatListProcessing || this.chatListPurgeActive) {
UI.log("⚠️ Chat list already running", "warning");
return;
}
const marked = this.getMarkedChats(container);
if (!marked.length) {
UI.log("⚠️ No chats selected", "warning");
return;
}
const wasMarkMode = this.chatListMarkMode;
UI.log(
`🧭 Chat list hide: ${marked.length} selected, markMode=${wasMarkMode ? "ON" : "OFF"}`,
);
const restoreMarkMode = this.beginListProcessing();
state.chatListProgress = { current: 0, total: marked.length };
this.chatListProcessing = true;
state.cancelChatList = false;
UI.setHeaderStatus("running", "Hiding");
UI.updateChatListControls();
UI.updateControls();
UI.updateStatsDisplay();
let completed = 0;
let failed = 0;
for (const item of marked) {
if (state.cancelChatList) break;
const username = this.getChatUsername(item);
const roomId = this.getChatRoomId(item) || "unknown";
UI.log(`📱 Opening chat with ${username}...`);
UI.log(`➡️ Target: ${username} (room=${roomId})`);
const nav = await this.navigateToChat(item);
if (!nav.success) {
UI.log(`❌ Failed to open chat: ${nav.reason}`, "error");
failed++;
state.chatListProgress.current++;
UI.updateStatsDisplay();
continue;
}
const hideOk = await HideChat.execute();
state.chatListProgress.current++;
if (hideOk) {
this.markChat(item, false);
state.chatsProcessed++;
completed++;
} else {
failed++;
}
UI.updateStatsDisplay();
await Utils.sleep(500);
}
this.chatListProcessing = false;
state.cancelChatList = false;
state.chatListProgress = { current: 0, total: 0 };
this.endListProcessing(restoreMarkMode);
UI.setHeaderStatus("idle");
UI.updateControls();
UI.updateStatsDisplay();
UI.log(`🏁 Hide complete: ${completed} hidden, ${failed} failed`);
UI.updateChatListCount();
UI.updateChatListControls();
},
getScrollableList(container) {
if (!container) return null;
let cur = container;
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;
}
return container;
},
async scrollDown(scrollEl) {
if (!scrollEl) return;
const before = scrollEl.scrollTop;
scrollEl.scrollTop = Math.min(
scrollEl.scrollHeight,
scrollEl.scrollTop + 900,
);
if (scrollEl.scrollTop === before) {
scrollEl.scrollTop = Math.min(
scrollEl.scrollHeight,
scrollEl.scrollTop + 450,
);
}
await Utils.sleep(250);
},
async scrollHalfScreen(scrollEl) {
if (!scrollEl) return;
const step = Math.max(
120,
Math.floor((scrollEl.clientHeight || 600) / 2),
);
const before = scrollEl.scrollTop;
scrollEl.scrollTop = Math.min(
scrollEl.scrollHeight,
scrollEl.scrollTop + step,
);
if (scrollEl.scrollTop === before) {
scrollEl.scrollTop = Math.min(
scrollEl.scrollHeight,
scrollEl.scrollTop + Math.floor(step / 2),
);
}
await Utils.sleep(250);
},
async purge() {
const container = this.findContainer();
if (!container) {
UI.log("❌ Could not find chat list", "error");
return;
}
if (this.chatListProcessing || this.chatListPurgeActive) {
UI.log("⚠️ Purge already running", "warning");
return;
}
const restoreMarkMode = this.beginListProcessing();
// Initialise progress — total unknown at start for purge
state.chatListProgress = { current: 0, total: 0 };
this.chatListProcessing = true;
this.chatListPurgeActive = true;
state.cancelChatList = false;
UI.setHeaderStatus("running", "Purging");
UI.updateChatListControls();
UI.updateControls();
UI.updateStatsDisplay();
UI.log("🧹 Purge started");
const processed = new Set();
let stalls = 0;
let totalProcessed = 0;
let processedSinceLastScroll = 0;
try {
const initialScrollEl = this.getScrollableList(container);
if (initialScrollEl) {
initialScrollEl.scrollTop = 0;
await Utils.sleep(180);
}
while (this.chatListPurgeActive && !state.cancelChatList) {
const items = this.getChatItems(container);
for (const item of items) {
const roomId = this.getChatRoomId(item);
// Skip MMCIs if processModMails is disabled
if (this.isModmailChat(item) && !Settings.get("processModMails"))
continue;
if (roomId && !processed.has(roomId)) {
this.purgePendingRoomIds.add(roomId);
// Increment total as we discover new rooms
state.chatListProgress.total++;
UI.updateStatsDisplay();
}
}
this.syncPurgeHighlights(container);
for (const item of items) {
if (!this.chatListPurgeActive || state.cancelChatList) break;
const roomId = this.getChatRoomId(item);
if (!roomId || processed.has(roomId)) continue;
// Skip MMCIs if processModMails is disabled
if (this.isModmailChat(item) && !Settings.get("processModMails")) {
processed.add(roomId);
this.purgePendingRoomIds.delete(roomId);
this.syncPurgeHighlights(container);
continue;
}
item.scrollIntoView({ block: "center", inline: "nearest" });
await Utils.sleep(80);
this.purgeProcessingRoomId = roomId;
this.syncPurgeHighlights(container);
const result = await this.processSingleChat(item);
processed.add(roomId);
this.purgePendingRoomIds.delete(roomId);
this.purgeProcessingRoomId = null;
totalProcessed++;
processedSinceLastScroll++;
state.chatListProgress.current++;
UI.updateStatsDisplay();
this.syncPurgeHighlights(container);
if (!result.success) {
UI.log(`⚠️ Skipped chat (${roomId})`, "warning");
}
if (processedSinceLastScroll >= 9) {
const scrollEl = this.getScrollableList(container);
await this.scrollHalfScreen(scrollEl);
this.syncPurgeHighlights(container);
processedSinceLastScroll = 0;
}
}
if (!this.chatListPurgeActive) break;
const scrollEl = this.getScrollableList(container);
if (!scrollEl) break;
const hasNew = this.getChatItems(container).some((item) => {
const id = this.getChatRoomId(item);
return id && !processed.has(id);
});
if (!hasNew) {
await this.scrollDown(scrollEl);
this.syncPurgeHighlights(container);
const postScrollNew = this.getChatItems(container).some((item) => {
const id = this.getChatRoomId(item);
return id && !processed.has(id);
});
if (!postScrollNew) {
stalls++;
UI.log(`⬇️ No new chats (${stalls}/4)`);
if (stalls >= 4) break;
} else {
stalls = 0;
}
} else {
stalls = 0;
}
}
} finally {
const wasStopped = !!state.cancelChatList;
this.purgePendingRoomIds.clear();
this.purgeProcessingRoomId = null;
this.syncPurgeHighlights(container);
this.chatListProcessing = false;
this.chatListPurgeActive = false;
state.cancelChatList = false;
state.chatListProgress = { current: 0, total: 0 };
this.endListProcessing(restoreMarkMode);
if (wasStopped) {
UI.flashHeaderStatus("stopped", "Stopped");
} else {
UI.setHeaderStatus("idle");
}
UI.updateControls();
UI.updateChatListControls();
UI.updateStatsDisplay();
UI.log(`🏁 Purge completed (${totalProcessed} processed)`);
// Note: Purge doesn't have hideAfterDeletion at purge level.
// Each processSingleChat() handles its own sweep+hide flow.
}
},
stopPurge() {
if (this.chatListPurgeActive) {
this.chatListPurgeActive = false;
UI.setHeaderStatus("stopping");
UI.log("⏸️ Stopping purge...");
}
if (this.chatListProcessing) {
state.cancelChatList = true;
}
this.purgePendingRoomIds.clear();
this.purgeProcessingRoomId = null;
this.syncPurgeHighlights(this.findContainer());
UI.updateControls();
UI.updateChatListControls();
},
};
// ========================= HIDE CHAT =========================
const HideChat = {
async findSettingsButton() {
const buttons = Utils.deepQueryAll("rs-room-settings-button");
for (const btn of buttons) {
if (btn.shadowRoot) {
const innerBtn = btn.shadowRoot.querySelector("button");
if (innerBtn && Utils.isVisible(innerBtn)) return innerBtn;
}
}
return null;
},
async findHideOption() {
const roomSettings = Utils.deepQueryAll("rs-room-settings");
for (const rs of roomSettings) {
if (!Utils.isVisible(rs) || !rs.shadowRoot) continue;
const hideBtn = rs.shadowRoot.querySelector(
"rs-room-settings-route-wrapper > faceplate-menu > li:nth-child(3) > div",
);
if (hideBtn && Utils.isVisible(hideBtn)) return hideBtn;
}
return null;
},
findConfirmButton() {
const dialogs = Utils.deepQueryAll("#rs-confirmation-modal-dialog");
for (const dialog of dialogs) {
if (!dialog.shadowRoot) continue;
const slot = dialog.shadowRoot.querySelector("slot");
if (!slot) continue;
const content = slot
.assignedElements({ flatten: true })
.find((el) => el.tagName === "RS-RPL-DIALOG-CONTENT");
if (!content?.shadowRoot) continue;
const confirmBtn = content.shadowRoot.querySelector(
"button.button-primary",
);
if (confirmBtn) return confirmBtn;
}
return null;
},
async waitForConfirmButton() {
const start = performance.now();
while (performance.now() - start < CONFIG.HIDE_CHAT_DIALOG_TIMEOUT_MS) {
const btn = this.findConfirmButton();
if (btn) return btn;
await Utils.sleep(CONFIG.DIALOG_POLL_INTERVAL_MS);
}
return null;
},
async executeForModmail() {
const settingsBtn = await this.findSettingsButton();
if (!settingsBtn) {
UI.log("❌ Could not find settings button for mod mail", "error");
return false;
}
settingsBtn.click();
await Utils.sleep(CONFIG.MENU_WAIT_MS * 2);
// For mod mail, use the specific selector for hide button
const roomSettings = Utils.deepQueryAll("rs-room-settings");
let hideBtn = null;
for (const rs of roomSettings) {
if (!Utils.isVisible(rs) || !rs.shadowRoot) continue;
const btn = rs.shadowRoot.querySelector(
"rs-room-settings-route-wrapper > faceplate-menu > li > div",
);
if (btn && Utils.isVisible(btn)) {
hideBtn = btn;
break;
}
}
if (!hideBtn) {
UI.log("❌ Could not find hide button for mod mail", "error");
return false;
}
hideBtn.click();
await Utils.sleep(CONFIG.DIALOG_WAIT_MS * 2);
const confirmBtn = await this.waitForConfirmButton();
if (!confirmBtn) {
UI.log("❌ Could not find confirm button for mod mail", "error");
return false;
}
confirmBtn.click();
await Utils.sleep(CONFIG.DIALOG_WAIT_MS);
return true;
},
async execute() {
UI.log("🔒 Hiding chat...");
const settingsBtn = await this.findSettingsButton();
if (!settingsBtn) {
UI.log("❌ Could not find settings button", "error");
return false;
}
settingsBtn.click();
await Utils.sleep(CONFIG.MENU_WAIT_MS * 2);
const hideOption = await this.findHideOption();
if (!hideOption) {
UI.log("❌ Could not find hide option", "error");
return false;
}
hideOption.click();
await Utils.sleep(CONFIG.DIALOG_WAIT_MS * 2);
const confirmBtn = await this.waitForConfirmButton();
if (!confirmBtn) {
UI.log("❌ Could not find confirm button", "error");
return false;
}
confirmBtn.click();
await Utils.sleep(CONFIG.DIALOG_WAIT_MS);
UI.log("✓ Chat hidden successfully", "success");
return true;
},
};
// ========================= UI =========================
const UI = {
panel: null,
statusResetTimer: null,
init() {
Settings.init();
this.createPanel();
this.attachEventListeners();
this.syncSettingsToUI();
this.switchTab("chat");
this.log("✨ UI scaffold initialized");
this.setHeaderStatus("idle");
// Poll for container with events, binds as soon as found or after max wait
(async () => {
await Containers.bindMainContainerWithPolling({
maxWaitMs: CONFIG.CHAT_CONTAINER_WAIT_MS,
pollIntervalMs: 200,
silent: true,
});
this.startUrlWatcher();
this.updateStatsDisplay();
this.updateControls();
})();
},
createPanel() {
const panel = document.createElement("div");
panel.id = "rcd-panel";
panel.innerHTML = `
<style>
#rcd-panel {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
width: 380px;
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border-radius: 16px;
box-shadow: 0 20px 50px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.1);
transition: width 0.3s ease, height 0.3s ease;
height: min(82vh, 760px);
display: flex;
flex-direction: column;
}
#rcd-panel.minimized {
width: fit-content;
height: auto;
}
#rcd-panel.minimized .rcd-header {
justify-content: flex-start;
align-items: center;
gap: 8px;
}
#rcd-panel.minimized .rcd-content { display: none; }
#rcd-panel.minimized .rcd-header {
border-radius: 16px;
border-bottom: none;
}
.rcd-content {
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 16px;
color: #e2e8f0;
}
.rcd-header {
padding: 14px 16px;
background: rgba(255,255,255,0.05);
border-radius: 16px 16px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
cursor: move;
user-select: none;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.rcd-title {
font-weight: 600;
color: #fff;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
white-space: nowrap;
}
.rcd-header-status {
padding: 3px 8px;
margin-left: 12px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
border: 1px solid transparent;
white-space: nowrap;
line-height: 1.1;
}
.rcd-header-status.idle {
background: rgba(148, 163, 184, 0.16);
border-color: rgba(148, 163, 184, 0.35);
color: #cbd5e1;
}
.rcd-header-status.running {
background: rgba(16, 185, 129, 0.16);
border-color: rgba(16, 185, 129, 0.45);
color: #34d399;
}
.rcd-header-status.stopping {
background: rgba(245, 158, 11, 0.16);
border-color: rgba(245, 158, 11, 0.4);
color: #fbbf24;
}
.rcd-header-status.stopped {
background: rgba(100, 116, 139, 0.2);
border-color: rgba(100, 116, 139, 0.45);
color: #cbd5e1;
}
.rcd-header-status.error {
background: rgba(239, 68, 68, 0.16);
border-color: rgba(239, 68, 68, 0.45);
color: #f87171;
}
.rcd-header-controls {
display: flex;
gap: 6px;
margin-left: auto;
}
.rcd-header-btn {
width: 44px;
height: 24px;
border-radius: 6px;
border: none;
background: rgba(255,255,255,0.1);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 16px;
line-height: 1;
}
.rcd-header-btn:hover {
background: rgba(255,255,255,0.2);
transform: scale(1.1);
}
.rcd-header-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.rcd-header-btn-stop {
width: 64px;
height: 28px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.6px;
background: linear-gradient(135deg, #ef4444, #b91c1c);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
text-transform: uppercase;
}
.rcd-header-btn-stop:hover {
background: linear-gradient(135deg, #f87171, #dc2626);
}
.rcd-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
background: rgba(0,0,0,0.2);
padding: 4px;
border-radius: 10px;
}
.rcd-tab {
flex: 1;
padding: 8px 12px;
background: transparent;
border: none;
color: #94a3b8;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.rcd-tab:hover {
color: #e2e8f0;
background: rgba(255,255,255,0.05);
}
.rcd-tab.active {
background: linear-gradient(135deg, #10b981, #059669);
color: #000;
}
.rcd-tab-content {
display: none;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
flex: 1 1 auto;
padding-right: 2px;
}
.rcd-tab-content.active {
display: block;
}
.rcd-tab-content::-webkit-scrollbar {
width: 6px;
}
.rcd-tab-content::-webkit-scrollbar-thumb {
background: rgba(148,163,184,0.45);
border-radius: 999px;
}
/* ===== NEW STATS PANEL ===== */
#rcd-stats-display {
background: rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
padding: 8px 12px;
margin-bottom: 12px;
display: flex;
flex-direction: column;
gap: 0;
}
#rcd-stats-display.hidden {
display: none;
}
.rcd-stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.rcd-stat-row:last-child {
border-bottom: none;
}
.rcd-stat-row.hidden {
display: none;
}
.rcd-stat-lbl {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
}
.rcd-stat-val {
font-size: 12px;
font-weight: 700;
color: #e2e8f0;
font-family: 'SF Mono', 'Consolas', monospace;
}
.rcd-stat-val.accent {
color: #10b981;
}
/* ===== END STATS PANEL ===== */
.rcd-section {
margin-bottom: 14px;
}
.rcd-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #94a3b8;
margin-bottom: 8px;
}
.rcd-input-group {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.rcd-input {
flex: 1;
padding: 10px 12px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #fff;
font-size: 13px;
outline: none;
transition: all 0.2s;
}
select.rcd-input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid rgba(255,255,255,0.14);
appearance: auto;
}
select.rcd-input:hover {
background: #0f172a;
color: #f8fafc;
}
select.rcd-input:focus {
background: #0f172a;
color: #f8fafc;
border-color: #10b981;
}
select.rcd-input option {
background: #0b1220;
color: #e2e8f0;
}
.rcd-input:focus {
background: rgba(255,255,255,0.08);
border-color: #10b981;
}
.rcd-input::placeholder {
color: #64748b;
}
.rcd-btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
user-select: none;
min-height: 36px;
}
#rcd-save-username {
height: 40px;
}
.rcd-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.rcd-btn:active {
transform: translateY(0);
}
.rcd-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.rcd-btn-primary {
background: linear-gradient(135deg, #10b981, #059669);
color: #000;
}
.rcd-btn-danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #fff;
}
.rcd-btn-secondary {
background: rgba(255,255,255,0.1);
color: #fff;
}
.rcd-btn-group {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.rcd-btn-full {
grid-column: 1 / -1;
}
.rcd-log-container {
position: relative;
}
.rcd-log {
position: relative;
height: 180px;
max-height: 180px;
overflow-y: auto;
padding: 10px 10px 10px 10px;
background: rgba(0,0,0,0.3);
border-radius: 8px;
font-size: 11px;
line-height: 1.5;
font-family: 'SF Mono', 'Consolas', monospace;
flex: 0 0 180px;
scrollbar-width: thin;
scrollbar-color: rgba(148,163,184,0.55) transparent;
}
.rcd-log::-webkit-scrollbar { width: 6px; }
.rcd-log::-webkit-scrollbar-track { background: transparent; }
.rcd-log::-webkit-scrollbar-thumb {
background: rgba(148,163,184,0.55);
border-radius: 999px;
}
.rcd-log::-webkit-scrollbar-thumb:hover {
background: rgba(148,163,184,0.75);
}
.rcd-log.hidden { display: none; }
.rcd-log-tools {
position: absolute;
top: 6px;
right: 6px;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
background: rgba(0,0,0,0.25);
backdrop-filter: blur(2px);
border-radius: 8px;
z-index: 2;
}
.rcd-log-btn {
width: 20px;
height: 20px;
border-radius: 6px;
border: none;
background: rgba(255,255,255,0.08);
color: #e2e8f0;
cursor: pointer;
font-size: 10px;
line-height: 1;
}
.rcd-log-btn:hover { background: rgba(255,255,255,0.18); }
.rcd-log-entry {
margin-bottom: 4px;
opacity: 0;
animation: fadeIn 0.3s forwards;
}
.rcd-log-entry.error { color: #ef4444; }
.rcd-log-entry.warning { color: #f59e0b; }
.rcd-log-entry.success { color: #10b981; }
.rcd-log-time {
color: #64748b;
margin-right: 6px;
}
@keyframes fadeIn { to { opacity: 1; } }
.rcd-toggle {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
padding: 8px 10px;
border-radius: 10px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
font-size: 12px;
margin-bottom: 8px;
}
.rcd-toggle-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.rcd-toggle.compact {
padding: 6px 8px;
font-size: 11px;
margin-bottom: 0;
min-height: 24px;
}
.rcd-toggle.compact > div:first-child {
font-weight: 600;
color: #e2e8f0;
}
.rcd-switch {
width: 40px;
height: 22px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.08);
position: relative;
cursor: pointer;
flex: 0 0 auto;
}
.rcd-switch::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255,255,255,0.85);
transition: transform 0.18s ease;
}
.rcd-switch.on {
background: rgba(16,185,129,0.35);
border-color: rgba(16,185,129,0.45);
}
.rcd-switch.on::after {
transform: translateX(18px);
background: #ffffff;
}
.rcd-settings-group { margin-bottom: 14px; }
.rcd-settings-group-title {
font-size: 12px;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.rcd-input-labeled { margin-bottom: 12px; }
.rcd-input-label {
font-size: 11px;
color: #94a3b8;
margin-bottom: 6px;
display: block;
}
.rcd-input-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.rcd-input-row .rcd-input-labeled {
flex: 1 1 160px;
margin-bottom: 0;
}
.rcd-settings-actions {
display: flex;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgba(255,255,255,0.1);
}
rs-timeline-event.rc-marked {
background: rgba(34,197,94,0.07);
box-shadow: inset 3px 0 0 #22c55e;
}
</style>
<div class="rcd-header">
<div class="rcd-title">
<span>💬 Chat Delete</span>
</div>
<span id="rcd-header-status" class="rcd-header-status idle">Idle</span>
<div class="rcd-header-controls">
<button class="rcd-header-btn rcd-header-btn-stop" id="rcd-stop-all" title="Stop all" style="display:none;">STOP</button>
<button class="rcd-header-btn" id="rcd-minimize" title="Minimize">−</button>
</div>
</div>
<div class="rcd-content">
<div class="rcd-tabs">
<button class="rcd-tab active" data-tab="chat">Current Chat</button>
<button class="rcd-tab" data-tab="list">Chat List</button>
<button class="rcd-tab" data-tab="settings">Settings</button>
</div>
<!-- Stats panel — shared, shown for chat + list tabs -->
<div id="rcd-stats-display">
<div class="rcd-stat-row" id="rcd-stat-row-1">
<span class="rcd-stat-lbl" id="rcd-stat-lbl-1">Selected</span>
<span class="rcd-stat-val accent" id="rcd-stat-val-1">0</span>
</div>
<div class="rcd-stat-row hidden" id="rcd-stat-row-2">
<span class="rcd-stat-lbl" id="rcd-stat-lbl-2">Deleting</span>
<span class="rcd-stat-val accent" id="rcd-stat-val-2">0 / 0</span>
</div>
<div class="rcd-stat-row" id="rcd-stat-row-3">
<span class="rcd-stat-lbl" id="rcd-stat-lbl-3">Session</span>
<span class="rcd-stat-val" id="rcd-stat-val-3">0</span>
</div>
</div>
<!-- Current Chat Tab -->
<div class="rcd-tab-content active" id="rcd-tab-chat">
<div class="rcd-section">
<div class="rcd-section-title">🎯 Select</div>
<div class="rcd-btn-group">
<button class="rcd-btn rcd-btn-secondary" id="rcd-auto-mark">
✓ Select All
</button>
<button class="rcd-btn rcd-btn-secondary" id="rcd-toggle-mark">
🖱️ Mark Mode
</button>
<button class="rcd-btn rcd-btn-secondary" id="rcd-clear-marks">
✕ Clear Selection
</button>
<button class="rcd-btn rcd-btn-danger" id="rcd-delete-marked">
🗑️ Delete Selected
</button>
</div>
</div>
<div class="rcd-section">
<div class="rcd-section-title">⚡ Actions</div>
<div class="rcd-btn-group">
<button class="rcd-btn rcd-btn-primary" id="rcd-primary-action">
🧹 Auto Sweep
</button>
<button class="rcd-btn rcd-btn-secondary" id="rcd-hide-chat-now">
👁️ Hide Chat
</button>
</div>
</div>
<div class="rcd-section">
<div class="rcd-section-title">⚙️ Options</div>
<div class="rcd-toggle-row">
<div class="rcd-toggle compact" id="rcd-opt-log">
<div>Show logs</div>
<div class="rcd-switch" id="rcd-switch-log"></div>
</div>
<div class="rcd-toggle compact" id="rcd-opt-hide">
<div>Hide after deletion</div>
<div class="rcd-switch" id="rcd-switch-hide"></div>
</div>
</div>
</div>
<div class="rcd-log-container">
<div class="rcd-log" id="rcd-log"></div>
<div class="rcd-log-tools">
<button class="rcd-log-btn" id="rcd-log-copy" title="Copy log">⧉</button>
<button class="rcd-log-btn" id="rcd-log-clear" title="Clear log">🗑</button>
</div>
</div>
</div>
<!-- Chat List Tab -->
<div class="rcd-tab-content" id="rcd-tab-list">
<div class="rcd-section">
<div class="rcd-section-title">🎯 Select</div>
<div class="rcd-btn-group">
<button class="rcd-btn rcd-btn-secondary" id="rcd-chat-select-all">
✓ Select All
</button>
<button class="rcd-btn rcd-btn-secondary" id="rcd-chat-mark-mode">
🖱️ Mark Mode
</button>
<button class="rcd-btn rcd-btn-secondary" id="rcd-chat-clear-all">
✕ Clear Selection
</button>
<button class="rcd-btn rcd-btn-danger" id="rcd-process-chats">
🗑️ Process Selected
</button>
</div>
<div style="font-size: 11px; color: #94a3b8; margin-top: 8px; line-height: 1.35;">
Enable Mark Mode, then click chats in the left pane to select them.
</div>
</div>
<div class="rcd-section">
<div class="rcd-section-title">⚡ Actions</div>
<div class="rcd-btn-group">
<button class="rcd-btn rcd-btn-primary" id="rcd-chat-sweep">
🧹 Purge
</button>
<button class="rcd-btn rcd-btn-secondary" id="rcd-hide-chats-only">
👁️ Hide Selected Chats
</button>
</div>
<div style="font-size: 11px; color: #94a3b8; margin-top: 8px; line-height: 1.35;">
Process: Delete messages + hide (if enabled)<br>
Hide Only: Just hide without deleting
</div>
</div>
<div class="rcd-section">
<div class="rcd-section-title">⚙️ Options</div>
<div class="rcd-toggle-row">
<div class="rcd-toggle compact" id="rcd-opt-log-list">
<div>Show logs</div>
<div class="rcd-switch" id="rcd-switch-log-list"></div>
</div>
<div class="rcd-toggle compact" id="rcd-opt-hide-batch">
<div>Hide after deletion</div>
<div class="rcd-switch" id="rcd-switch-hide-batch"></div>
</div>
</div>
</div>
<div class="rcd-log-container">
<div class="rcd-log" id="rcd-log-chatlist"></div>
<div class="rcd-log-tools">
<button class="rcd-log-btn" id="rcd-loglist-copy" title="Copy log">⧉</button>
<button class="rcd-log-btn" id="rcd-loglist-clear" title="Clear log">🗑</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div class="rcd-tab-content" id="rcd-tab-settings">
<div class="rcd-settings-scroll">
<div class="rcd-settings-group">
<div class="rcd-settings-group-title">General</div>
<div class="rcd-input-labeled">
<label class="rcd-input-label">Reddit username</label>
<div class="rcd-input-group">
<input
type="text"
class="rcd-input"
id="rcd-username"
placeholder="Your Reddit username (no u/)"
value=""
>
<button class="rcd-btn rcd-btn-secondary" id="rcd-save-username">Save</button>
</div>
</div>
<div class="rcd-input-row">
<div class="rcd-input-labeled">
<label class="rcd-input-label">Deletion delay (ms)</label>
<input type="number" class="rcd-input" id="rcd-setting-delay" min="100" max="5000" step="50">
</div>
<div class="rcd-input-labeled">
<label class="rcd-input-label">Max deletions per minute</label>
<input type="number" class="rcd-input" id="rcd-setting-max-deletions" min="1" max="120">
</div>
</div>
<div class="rcd-input-row">
<div class="rcd-input-labeled">
<label class="rcd-input-label">Empty chat load wait (seconds)</label>
<input type="number" class="rcd-input" id="rcd-setting-empty-chat-wait-sec" min="0" max="30" step="0.5">
</div>
<div class="rcd-input-labeled">
<label class="rcd-input-label">Marked message style</label>
<select class="rcd-input" id="rcd-setting-mark-style">
<option value="modern">Modern (highlight)</option>
<option value="border">Border (1px)</option>
</select>
</div>
</div>
<div class="rcd-toggle" id="rcd-opt-modmail">
<div>
<div style="font-weight:600; color:#e2e8f0;">Process Mod Mail Chats</div>
<div style="color:#94a3b8;">Allow selection and hiding of mod mail chats (read-only)</div>
</div>
<div class="rcd-switch" id="rcd-switch-modmail"></div>
</div>
<div class="rcd-toggle" id="rcd-opt-minimize">
<div>
<div style="font-weight:600; color:#e2e8f0;">Auto-minimize after deletion</div>
<div style="color:#94a3b8;">Minimize widget when sweep completes</div>
</div>
<div class="rcd-switch" id="rcd-switch-minimize"></div>
</div>
</div>
<div class="rcd-settings-actions">
<button class="rcd-btn rcd-btn-secondary" id="rcd-reset-settings" style="flex: 1;">
Reset Defaults
</button>
<button class="rcd-btn rcd-btn-primary" id="rcd-save-settings" style="flex: 1;">
Save Settings
</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(panel);
this.panel = panel;
},
attachEventListeners() {
const safeOn = (id, event, handler) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener(event, handler);
};
this.enableDrag();
safeOn("rcd-minimize", "click", () => {
const minimizing = !this.panel.classList.contains("minimized");
this.panel.classList.toggle("minimized", minimizing);
if (minimizing) {
this.dockToCorner();
} else {
this.clampPanelToViewport();
}
const btn = document.getElementById("rcd-minimize");
if (btn) btn.textContent = minimizing ? "▢" : "−";
});
window.addEventListener("resize", () => {
if (!this.panel) return;
if (this.panel.classList.contains("minimized")) {
this.dockToCorner();
return;
}
const hasManualPosition =
!!this.panel.style.left && this.panel.style.left !== "auto";
if (hasManualPosition) {
this.clampPanelToViewport();
}
});
safeOn("rcd-stop-all", "click", (e) => {
e.preventDefault();
e.stopPropagation();
this.stopAll("user");
});
const tabs = this.panel.querySelectorAll(".rcd-tab");
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
this.switchTab(tab.dataset.tab);
});
});
safeOn("rcd-save-username", "click", () => {
const input = document.getElementById("rcd-username");
const username = (input?.value || "").trim();
Settings.set("username", username);
this.log(`💾 Username saved: ${username || "(cleared)"}`);
this.updateControls();
});
safeOn("rcd-save-settings", "click", () => {
const delayEl = document.getElementById("rcd-setting-delay");
const maxEl = document.getElementById("rcd-setting-max-deletions");
const emptyWaitEl = document.getElementById(
"rcd-setting-empty-chat-wait-sec",
);
const markEl = document.getElementById("rcd-setting-mark-style");
const rawDelay = parseInt(delayEl?.value || "0", 10) || 0;
const rawMax = parseInt(maxEl?.value || "0", 10) || 0;
const rawEmptyWait =
parseFloat(
emptyWaitEl?.value || String(Settings.get("emptyChatLoadWaitSec")),
) || 0;
const delayVal = Math.min(5000, Math.max(0, rawDelay));
const maxVal = Math.max(0, rawMax);
const emptyWaitVal = Math.min(30, Math.max(0, rawEmptyWait));
const markVal = (markEl?.value || "modern").toLowerCase();
Settings.set("deletionDelayMs", delayVal);
Settings.set("maxDeletionsPerMinute", maxVal);
Settings.set("emptyChatLoadWaitSec", emptyWaitVal);
Settings.set("markStyle", markVal === "border" ? "border" : "modern");
this.syncSettingsToUI();
this.log("💾 Settings saved");
});
safeOn("rcd-reset-settings", "click", () => {
Settings.current = { ...DEFAULT_SETTINGS };
Settings.save();
this.syncSettingsToUI();
this.log("↩️ Settings reset to defaults");
});
safeOn("rcd-auto-mark", "click", () => {
const username = Settings.get("username");
if (!username) {
this.log("❌ Please save username first", "error");
return;
}
const total = Selection.autoSelectBoth(username);
this.log(`✓ Total marked: ${total}`);
this.updateMarkedCount();
this.updateStatsDisplay();
this.updateControls();
});
safeOn("rcd-toggle-mark", "click", () => {
if (!state.containers.main) {
this.log("❌ Select a chat first", "error");
return;
}
state.markMode = !state.markMode;
const btn = document.getElementById("rcd-toggle-mark");
if (btn) {
btn.textContent = state.markMode
? "🖱️ Mark Mode (ON)"
: "🖱️ Mark Mode";
btn.style.background = state.markMode
? "linear-gradient(135deg, #10b981, #059669)"
: "";
}
Selection.attach(state.containers.main);
if (state.containers.reply) Selection.attach(state.containers.reply);
this.log(state.markMode ? "🖱️ Mark Mode ON" : "🖱️ Mark Mode OFF");
this.updateControls();
});
safeOn("rcd-clear-marks", "click", () => {
if (!state.containers.main) {
this.log("❌ Select a chat first", "error");
return;
}
Selection.clearAll(state.containers.main);
if (state.containers.reply) Selection.clearAll(state.containers.reply);
this.log("✓ Cleared selection");
this.updateMarkedCount();
this.updateStatsDisplay();
this.updateControls();
});
safeOn("rcd-delete-marked", "click", async (e) => {
e.preventDefault();
e.stopImmediatePropagation();
await Deletion.deleteSelected(state.containers.main);
this.updateControls();
});
safeOn("rcd-primary-action", "click", async (e) => {
e.preventDefault();
e.stopPropagation();
if (state.sweepActive) {
Sweep.stop();
return;
}
await Sweep.run();
});
safeOn("rcd-hide-chat-now", "click", async (e) => {
e.preventDefault();
e.stopPropagation();
await HideChat.execute();
});
safeOn("rcd-log-clear", "click", () => {
const log = document.getElementById("rcd-log");
if (log)
log.querySelectorAll(".rcd-log-entry").forEach((e) => e.remove());
});
safeOn("rcd-log-copy", "click", async () => {
const log = document.getElementById("rcd-log");
if (!log) return;
const text = Array.from(log.querySelectorAll(".rcd-log-entry"))
.map((el) => el.textContent || "")
.join("\n");
if (!text) return;
try {
await navigator.clipboard.writeText(text);
this.log("📋 Log copied", "success");
} catch {
this.log("❌ Copy failed", "error");
}
});
safeOn("rcd-loglist-clear", "click", () => {
const log = document.getElementById("rcd-log-chatlist");
if (log)
log.querySelectorAll(".rcd-log-entry").forEach((e) => e.remove());
});
safeOn("rcd-loglist-copy", "click", async () => {
const log = document.getElementById("rcd-log-chatlist");
if (!log) return;
const text = Array.from(log.querySelectorAll(".rcd-log-entry"))
.map((el) => el.textContent || "")
.join("\n");
if (!text) return;
try {
await navigator.clipboard.writeText(text);
this.log("📋 Log copied", "success");
} catch {
this.log("❌ Copy failed", "error");
}
});
safeOn("rcd-opt-log", "click", (e) => {
e.preventDefault();
e.stopPropagation();
Settings.set("showLog", !Settings.get("showLog"));
this.syncSettingsToUI();
this.log(`Show logs: ${Settings.get("showLog") ? "ON" : "OFF"}`);
});
safeOn("rcd-opt-log-list", "click", (e) => {
e.preventDefault();
e.stopPropagation();
Settings.set("showLog", !Settings.get("showLog"));
this.syncSettingsToUI();
this.log(`Show logs: ${Settings.get("showLog") ? "ON" : "OFF"}`);
});
safeOn("rcd-opt-hide", "click", (e) => {
e.preventDefault();
e.stopPropagation();
Settings.set("hideAfterDeletion", !Settings.get("hideAfterDeletion"));
this.syncSettingsToUI();
this.log(
`Hide after deletion: ${Settings.get("hideAfterDeletion") ? "ON" : "OFF"}`,
);
});
safeOn("rcd-opt-hide-batch", "click", (e) => {
e.preventDefault();
e.stopPropagation();
Settings.set("hideAfterDeletion", !Settings.get("hideAfterDeletion"));
this.syncSettingsToUI();
this.log(
`Hide after deletion: ${Settings.get("hideAfterDeletion") ? "ON" : "OFF"}`,
);
});
safeOn("rcd-opt-minimize", "click", (e) => {
e.preventDefault();
e.stopPropagation();
Settings.set(
"autoMinimizeAfterDeletion",
!Settings.get("autoMinimizeAfterDeletion"),
);
this.syncSettingsToUI();
this.log(
`Auto-minimize: ${Settings.get("autoMinimizeAfterDeletion") ? "ON" : "OFF"}`,
);
});
safeOn("rcd-opt-modmail", "click", (e) => {
e.preventDefault();
e.stopPropagation();
Settings.set("processModMails", !Settings.get("processModMails"));
this.syncSettingsToUI();
this.log(
`Process Mod Mails: ${Settings.get("processModMails") ? "ON" : "OFF"}`,
);
});
// Chat List controls
safeOn("rcd-chat-mark-mode", "click", () => {
ChatList.chatListMarkMode = !ChatList.chatListMarkMode;
const btn = document.getElementById("rcd-chat-mark-mode");
if (btn) {
btn.textContent = ChatList.chatListMarkMode
? "🖱️ Mark Mode (ON)"
: "🖱️ Mark Mode";
btn.style.background = ChatList.chatListMarkMode
? "linear-gradient(135deg, #10b981, #059669)"
: "";
}
const container = ChatList.findContainer();
if (container) {
ChatList.attachChatClickHandlers(container);
} else {
this.log("❌ Could not find chat list", "error");
}
this.updateChatListControls();
});
safeOn("rcd-chat-select-all", "click", () => {
const container = ChatList.findContainer();
if (!container) {
this.log("❌ Could not find chat list", "error");
return;
}
const count = ChatList.selectAllChats(container);
this.log(`✓ Selected all ${count} chats`);
this.updateChatListCount();
this.updateChatListControls();
});
safeOn("rcd-chat-clear-all", "click", () => {
const container = ChatList.findContainer();
if (!container) {
this.log("❌ Could not find chat list", "error");
return;
}
ChatList.clearAllChats(container);
this.log("✓ Cleared all selections");
this.updateChatListCount();
this.updateChatListControls();
});
safeOn("rcd-process-chats", "click", async () => {
const container = ChatList.findContainer();
if (!container) {
this.log("❌ Could not find chat list", "error");
return;
}
await ChatList.processSelectedChats(container);
});
safeOn("rcd-hide-chats-only", "click", async () => {
const container = ChatList.findContainer();
if (!container) {
this.log("❌ Could not find chat list", "error");
return;
}
await ChatList.hideSelectedChats(container);
});
safeOn("rcd-chat-sweep", "click", () => {
if (ChatList.chatListProcessing || ChatList.chatListPurgeActive) {
ChatList.stopPurge();
return;
}
ChatList.purge();
});
},
switchTab(tabName) {
state.currentTab = tabName;
const tabs = this.panel.querySelectorAll(".rcd-tab");
const contents = this.panel.querySelectorAll(".rcd-tab-content");
tabs.forEach((tab) => {
tab.classList.toggle("active", tab.dataset.tab === tabName);
});
contents.forEach((content) => {
content.classList.toggle("active", content.id === `rcd-tab-${tabName}`);
});
if (tabName === "list") {
const container = ChatList.findContainer();
if (container) ChatList.attachChatClickHandlers(container);
}
this.updateStatsDisplay();
},
updateMarkedCount() {
let count = 0;
if (state.containers.main) {
count = Selection.getMarked(state.containers.main).length;
}
state.markedCount = count;
},
// ========================= STATS DISPLAY (REWRITTEN) =========================
updateStatsDisplay() {
const statsEl = document.getElementById("rcd-stats-display");
if (!statsEl) return;
const lbl1 = document.getElementById("rcd-stat-lbl-1");
const val1 = document.getElementById("rcd-stat-val-1");
const row2 = document.getElementById("rcd-stat-row-2");
const lbl2 = document.getElementById("rcd-stat-lbl-2");
const val2 = document.getElementById("rcd-stat-val-2");
const lbl3 = document.getElementById("rcd-stat-lbl-3");
const val3 = document.getElementById("rcd-stat-val-3");
// Hide stats on settings tab
if (state.currentTab === "settings") {
statsEl.classList.add("hidden");
return;
}
statsEl.classList.remove("hidden");
const isDeleting =
state.sweepActive ||
state.isBusy ||
ChatList.chatListProcessing ||
ChatList.chatListPurgeActive;
if (state.currentTab === "chat") {
// Row 1: messages selected
if (lbl1) lbl1.textContent = "Selected";
if (val1) val1.textContent = `${state.markedCount} msgs`;
// Row 2: deleting x/y — only shown while active
if (
isDeleting &&
(state.deleteProgress.current > 0 || state.deleteProgress.total > 0)
) {
row2?.classList.remove("hidden");
if (lbl2) lbl2.textContent = "Deleting";
if (val2)
val2.textContent = `${state.deleteProgress.current} / ${state.deleteProgress.total}`;
} else {
row2?.classList.add("hidden");
}
// Row 3: session deleted messages
if (lbl3) lbl3.textContent = "Session";
if (val3) val3.textContent = `${state.totalDeleted} deleted`;
} else {
// Chat list tab
const isRunning =
ChatList.chatListProcessing || ChatList.chatListPurgeActive;
// Row 1: chats selected / progress
if (lbl1) lbl1.textContent = "Selected";
if (isRunning && state.chatListProgress.total > 0) {
if (val1)
val1.textContent = `${state.chatListProgress.current} / ${state.chatListProgress.total} chats`;
} else {
if (val1) val1.textContent = `${ChatList.markedChatIds.size} chats`;
}
// Row 2: deleting x/y for current sweep inside the chat — shown while sweep is active
if (
isDeleting &&
(state.deleteProgress.current > 0 || state.deleteProgress.total > 0)
) {
row2?.classList.remove("hidden");
if (lbl2) lbl2.textContent = "Deleting";
if (val2)
val2.textContent = `${state.deleteProgress.current} / ${state.deleteProgress.total}`;
} else {
row2?.classList.add("hidden");
}
// Row 3: session chats processed
if (lbl3) lbl3.textContent = "Session";
if (val3) val3.textContent = `${state.chatsProcessed} chats done`;
}
},
updateControls() {
const btnAuto = document.getElementById("rcd-auto-mark");
const btnMark = document.getElementById("rcd-toggle-mark");
const btnClear = document.getElementById("rcd-clear-marks");
const btnDelete = document.getElementById("rcd-delete-marked");
const btnPrimary = document.getElementById("rcd-primary-action");
const btnStopAll = document.getElementById("rcd-stop-all");
const btnSaveSettings = document.getElementById("rcd-save-settings");
const btnResetSettings = document.getElementById("rcd-reset-settings");
const inputDelay = document.getElementById("rcd-setting-delay");
const inputMax = document.getElementById("rcd-setting-max-deletions");
const inputEmptyWait = document.getElementById(
"rcd-setting-empty-chat-wait-sec",
);
const inputUsername = document.getElementById("rcd-username");
const btnSaveUsername = document.getElementById("rcd-save-username");
const hasContainer = !!state.containers.main;
const hasSelection = state.markedCount > 0;
const hasUsername = !!Settings.get("username");
const isRunning =
state.sweepActive ||
state.isBusy ||
ChatList.chatListProcessing ||
ChatList.chatListPurgeActive;
if (btnAuto)
btnAuto.disabled = !hasContainer || !hasUsername || isRunning;
if (btnMark) btnMark.disabled = !hasContainer || isRunning;
if (btnClear)
btnClear.disabled = !hasContainer || !hasSelection || isRunning;
if (btnDelete)
btnDelete.disabled = !hasContainer || !hasSelection || isRunning;
if (btnPrimary) {
const sweepRunning = state.sweepActive || state.isBusy;
btnPrimary.disabled = !hasContainer || sweepRunning;
btnPrimary.textContent = "🧹 Auto Sweep";
btnPrimary.classList.remove("rcd-btn-danger");
btnPrimary.classList.add("rcd-btn-primary");
}
if (btnStopAll) {
btnStopAll.style.display = isRunning ? "flex" : "none";
btnStopAll.disabled = !isRunning;
}
if (btnSaveSettings) btnSaveSettings.disabled = isRunning;
if (btnResetSettings) btnResetSettings.disabled = isRunning;
if (inputDelay) inputDelay.disabled = isRunning;
if (inputMax) inputMax.disabled = isRunning;
if (inputEmptyWait) inputEmptyWait.disabled = isRunning;
if (inputUsername) inputUsername.disabled = isRunning;
if (btnSaveUsername) btnSaveUsername.disabled = isRunning;
},
stopAll(reason = "user") {
state.cancelSweep = true;
state.cancelDelete = true;
state.cancelChatList = true;
Throttle.reset();
if (state.sweepActive) Sweep.stop();
if (ChatList.chatListPurgeActive) ChatList.stopPurge();
ChatList.chatListProcessing = false;
state.isBusy = false;
this.setHeaderStatus("stopping");
this.updateControls();
this.updateChatListControls();
this.log(`■ Stop requested (${reason})`, "warning");
},
updateChatListCount() {
this.updateStatsDisplay();
},
updateChatListControls() {
const btnMarkMode = document.getElementById("rcd-chat-mark-mode");
const btnSelectAll = document.getElementById("rcd-chat-select-all");
const btnClearAll = document.getElementById("rcd-chat-clear-all");
const btnProcess = document.getElementById("rcd-process-chats");
const btnHideOnly = document.getElementById("rcd-hide-chats-only");
const btnSweep = document.getElementById("rcd-chat-sweep");
const hasSelection = ChatList.markedChatIds.size > 0;
const isProcessing = ChatList.chatListProcessing;
const isRunning =
ChatList.chatListProcessing || ChatList.chatListPurgeActive;
if (btnMarkMode) btnMarkMode.disabled = isProcessing;
if (btnSelectAll) btnSelectAll.disabled = isProcessing;
if (btnClearAll) btnClearAll.disabled = isProcessing || !hasSelection;
if (btnProcess) btnProcess.disabled = isProcessing || !hasSelection;
if (btnHideOnly) btnHideOnly.disabled = isProcessing || !hasSelection;
if (btnSweep) {
btnSweep.textContent = "🧹 Purge";
btnSweep.classList.remove("rcd-btn-danger");
btnSweep.classList.add("rcd-btn-primary");
btnSweep.disabled = isRunning;
}
},
setStatus(text, tone) {
const statusEl = document.getElementById("rcd-status-inline");
if (statusEl) {
statusEl.textContent = "";
statusEl.className = "rcd-status-inline";
}
if (!text) {
this.setHeaderStatus("idle");
return;
}
const normalized = String(text).toLowerCase();
if (tone === "warn" && normalized.includes("stop")) {
this.setHeaderStatus("stopping");
return;
}
if (tone === "warn") {
this.setHeaderStatus("stopped");
return;
}
this.setHeaderStatus("running", text);
},
setHeaderStatus(stateName = "idle", text = "") {
const el = document.getElementById("rcd-header-status");
if (!el) return;
if (this.statusResetTimer) {
clearTimeout(this.statusResetTimer);
this.statusResetTimer = null;
}
const allowed = new Set([
"idle",
"running",
"stopping",
"stopped",
"error",
]);
const normalizedState = allowed.has(stateName) ? stateName : "idle";
const labelMap = {
idle: "Idle",
running: "Running",
stopping: "Stopping",
stopped: "Stopped",
error: "Error",
};
const label = text
? String(text)
.replace(/^🔄\s*/, "")
.trim()
: labelMap[normalizedState];
el.className = `rcd-header-status ${normalizedState}`;
el.textContent = label || labelMap[normalizedState];
},
flashHeaderStatus(stateName, text, delayMs = 1500) {
this.setHeaderStatus(stateName, text);
this.statusResetTimer = setTimeout(() => {
this.setHeaderStatus("idle");
}, delayMs);
},
syncSettingsToUI() {
const swLog = document.getElementById("rcd-switch-log");
const swLogList = document.getElementById("rcd-switch-log-list");
const swHide = document.getElementById("rcd-switch-hide");
const swHideBatch = document.getElementById("rcd-switch-hide-batch");
const swMin = document.getElementById("rcd-switch-minimize");
const swModmail = document.getElementById("rcd-switch-modmail");
const inputDelay = document.getElementById("rcd-setting-delay");
const inputMax = document.getElementById("rcd-setting-max-deletions");
const inputEmptyWait = document.getElementById(
"rcd-setting-empty-chat-wait-sec",
);
const inputMarkStyle = document.getElementById("rcd-setting-mark-style");
const inputUsername = document.getElementById("rcd-username");
const log = document.getElementById("rcd-log");
const logList = document.getElementById("rcd-log-chatlist");
if (swLog) swLog.classList.toggle("on", Settings.get("showLog"));
if (swLogList) swLogList.classList.toggle("on", Settings.get("showLog"));
if (swHide)
swHide.classList.toggle("on", Settings.get("hideAfterDeletion"));
if (swHideBatch)
swHideBatch.classList.toggle("on", Settings.get("hideAfterDeletion"));
if (swMin)
swMin.classList.toggle("on", Settings.get("autoMinimizeAfterDeletion"));
if (swModmail)
swModmail.classList.toggle("on", Settings.get("processModMails"));
if (log) log.classList.toggle("hidden", !Settings.get("showLog"));
if (logList) logList.classList.toggle("hidden", !Settings.get("showLog"));
if (inputDelay)
inputDelay.value = String(Settings.get("deletionDelayMs"));
if (inputMax)
inputMax.value = String(Settings.get("maxDeletionsPerMinute"));
if (inputEmptyWait)
inputEmptyWait.value = String(Settings.get("emptyChatLoadWaitSec"));
if (inputMarkStyle)
inputMarkStyle.value = String(Settings.get("markStyle") || "modern");
if (inputUsername)
inputUsername.value = String(Settings.get("username") || "");
},
log(message, type = "info") {
const logId = this.getActiveLogId();
const logEl = document.getElementById(logId);
if (!logEl) return;
const entry = document.createElement("div");
entry.className = `rcd-log-entry ${type}`;
const time = new Date().toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
entry.innerHTML = `<span class="rcd-log-time">[${time}]</span>${message}`;
logEl.insertBefore(entry, logEl.firstChild);
while (logEl.children.length > CONFIG.LOG_MAX_LINES) {
logEl.removeChild(logEl.lastChild);
}
},
getActiveLogId() {
const active = this.panel.querySelector(".rcd-tab-content.active");
if (!active) return "rcd-log";
if (active.id === "rcd-tab-list") return "rcd-log-chatlist";
return "rcd-log";
},
dockToCorner() {
if (!this.panel) return;
this.panel.style.left = "auto";
this.panel.style.top = "auto";
this.panel.style.right = "16px";
this.panel.style.bottom = "16px";
},
clampPanelToViewport() {
if (!this.panel) return;
const rect = this.panel.getBoundingClientRect();
const margin = 8;
const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
const maxTop = Math.max(
margin,
window.innerHeight - rect.height - margin,
);
const clampedLeft = Math.min(Math.max(rect.left, margin), maxLeft);
const clampedTop = Math.min(Math.max(rect.top, margin), maxTop);
this.panel.style.right = "auto";
this.panel.style.bottom = "auto";
this.panel.style.left = `${clampedLeft}px`;
this.panel.style.top = `${clampedTop}px`;
},
startUrlWatcher() {
state.lastUrl = location.href;
setInterval(() => {
if (state.lastUrl !== location.href) {
state.lastUrl = location.href;
Containers.bindMainContainer({ silent: true });
this.updateControls();
}
}, CONFIG.URL_WATCH_INTERVAL_MS);
},
enableDrag() {
const header = this.panel.querySelector(".rcd-header");
if (!header) return;
let isDragging = false;
let initialX = 0;
let initialY = 0;
let rafId = null;
header.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
if (e.target && e.target.closest("button, input, select, textarea, a"))
return;
isDragging = true;
initialX = e.clientX - this.panel.offsetLeft;
initialY = e.clientY - this.panel.offsetTop;
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
if (rafId) return;
rafId = requestAnimationFrame(() => {
const rect = this.panel.getBoundingClientRect();
const margin = 8;
const maxX = Math.max(
margin,
window.innerWidth - rect.width - margin,
);
const maxY = Math.max(
margin,
window.innerHeight - rect.height - margin,
);
const currentX = Math.min(
Math.max(e.clientX - initialX, margin),
maxX,
);
const currentY = Math.min(
Math.max(e.clientY - initialY, margin),
maxY,
);
this.panel.style.right = "auto";
this.panel.style.bottom = "auto";
this.panel.style.left = `${currentX}px`;
this.panel.style.top = `${currentY}px`;
rafId = null;
});
});
document.addEventListener("mouseup", () => {
isDragging = false;
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
this.clampPanelToViewport();
});
},
};
function init() {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
return;
}
setTimeout(() => {
UI.init();
}, CONFIG.INIT_DELAY_MS);
}
init();
})();