Instagram Bulk Remover

Bulk unlike/delete with stable duplicate button injection and settings dialog

// ==UserScript==
// @name         Instagram Bulk Remover
// @homepage     https://github.com/rahaaatul/userscripts
// @namespace    https://github.com/rahaaatul/userscripts
// @version      1.0.0
// @description  Bulk unlike/delete with stable duplicate button injection and settings dialog
// @author       Rahatul Ghazi
// @match        https://www.instagram.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @inject-into  content
// ==/UserScript==

(function () {
    'use strict';

    // =================================================================================
    // CONSTANTS & SETTINGS
    // =================================================================================
    const BULK_ACTIONS_SETTINGS_ID = 'insta-bulk-actions-settings';
    const IS_RUNNING_KEY = 'isBulkActionRunning';
    const DEFAULT_MAX_POSTS = 20, MIN_POSTS_LIMIT = 1, MAX_POSTS_LIMIT = 100;
    const DEFAULT_DELAY_MS = 1000, MIN_DELAY_MS = 0, MAX_DELAY_MS = 3000;
    let isRunning = false;
    let currentTimeout = null;

    const PAGE_CONFIGS = {
        likes: { url: '/your_activity/interactions/likes', appid: 'com.instagram.privacy.activity_center.liked_unlike', headerText: 'Unlike Settings', actionText: 'Unlike' },
        comments: { url: '/your_activity/interactions/comments', appid: 'com.instagram.privacy.activity_center.comments_delete', headerText: 'Uncomment Settings', actionText: 'Delete' },
        story_replies: { url: '/your_activity/interactions/story_replies', appid: 'com.instagram.privacy.activity_center.story_replies_delete', headerText: 'Unreply Settings', actionText: 'Delete' }
    };

    let nextCursor = null; // Variable to hold the pagination cursor

    function getCurrentPageConfig() {
        // Normalize current path by removing trailing slash if it exists
        const normalizedPath = window.location.pathname.replace(/\/$/, '');
        return Object.values(PAGE_CONFIGS).find(p =>
            // Also normalize the config URL for a robust comparison
            normalizedPath.startsWith(p.url));
    }

    function getConfig(key, defaultValue) { return GM_getValue(key, defaultValue); }
    function setConfig(key, value) { GM_setValue(key, value); }

    // =================================================================================
    // DIALOG & UI
    // =================================================================================
    function updateSliderVisuals(slider) {
        const valueDisplay = document.querySelector(`.native-slider-value[data-slider-id="${slider.id}"]`);
        if (valueDisplay) valueDisplay.textContent = slider.value;
    }
    // STABLE BUTTON INJECTION
    // =================================================================================
    const TARGET_SELECTOR = 'div[role="button"][aria-label="Sort & filter"]';

    function showSettingsDialog() {
        const pageConfig = getCurrentPageConfig();
        if (!pageConfig || document.getElementById(`${BULK_ACTIONS_SETTINGS_ID}-container`)) return;

        const maxPosts = getConfig('maxPosts', DEFAULT_MAX_POSTS);
        const delayMs = getConfig('delayMs', DEFAULT_DELAY_MS);
        const autoScroll = getConfig('autoScroll', false);

        const overlay = document.createElement('div');
        overlay.id = `${BULK_ACTIONS_SETTINGS_ID}-overlay`;
        overlay.addEventListener('click', closeSettingsDialog);

        const container = document.createElement('div');
        container.id = `${BULK_ACTIONS_SETTINGS_ID}-container`;
        container.innerHTML = `
            <div class="native-popup-header">
                <div class="native-popup-close"><svg aria-label="Close" fill="currentColor" height="24" role="img" viewBox="0 0 24 24" width="24"><path d="M18 6.4L17.6 6 12 11.6 6.4 6 6 6.4 11.6 12 6 17.6 6.4 18 12 12.4 17.6 18 18 17.6 12.4 12 18 6.4z"></path></svg></div>
                <h1 class="native-popup-title">${pageConfig.headerText}</h1>
            </div>
            <div class="native-popup-content">
                <div class="native-control-group">
                    <p class="native-slider-label" id="maxPostsLabel">Items per Batch</p>
                    <div class="native-slider-container">
                        <input type="range" id="maxPosts" min="${MIN_POSTS_LIMIT}" max="${MAX_POSTS_LIMIT}" value="${maxPosts}">
                        <span class="native-slider-value" data-slider-id="maxPosts">${maxPosts}</span>
                    </div>
                </div>
                <div class="native-control-group">
                    <p class="native-slider-label">Delay per Action (ms)</p>
                    <div class="native-slider-container">
                        <input type="range" id="delayMs" min="${MIN_DELAY_MS}" max="${MAX_DELAY_MS}" step="1" value="${delayMs}">
                        <span class="native-slider-value" data-slider-id="delayMs">${delayMs}</span>
                    </div>
                </div>
                <button id="run-stop-button"></button>
            </div>
        `;

        document.body.appendChild(overlay);
        document.body.appendChild(container);
        setTimeout(() => { overlay.classList.add('visible'); container.classList.add('visible'); }, 10);

        container.querySelector('.native-popup-close').addEventListener('click', closeSettingsDialog);
        container.querySelectorAll('input[type="range"]').forEach(slider => {
            updateSliderVisuals(slider); // Set initial value display
            slider.addEventListener('input', () => {
                updateSliderVisuals(slider);
                setConfig(slider.id, parseInt(slider.value));
            });
        });

        container.querySelector('#run-stop-button').addEventListener('click', toggleScript);
        updateRunButtonState();
    }

    function closeSettingsDialog() {
        const overlay = document.getElementById(`${BULK_ACTIONS_SETTINGS_ID}-overlay`);
        const container = document.getElementById(`${BULK_ACTIONS_SETTINGS_ID}-container`);
        if (overlay && container) {
            overlay.classList.remove('visible');
            container.classList.remove('visible');
            setTimeout(() => { overlay.remove(); container.remove(); }, 300);
        }
    }

    function updateRunButtonState() {
        const btn = document.getElementById('run-stop-button');
        if (!btn) return;
        btn.textContent = isRunning ? 'Stop' : 'Start';
        btn.setAttribute('data-running', isRunning);
    }

    function toggleScript() {
        isRunning = !isRunning;
        setConfig(IS_RUNNING_KEY, isRunning);
        updateRunButtonState();
        if (isRunning) runAutomationLoop();
        else if (currentTimeout) { clearTimeout(currentTimeout); currentTimeout = null; }
    }

    // Custom error for clean loop termination
    class ScriptStoppedError extends Error {
        constructor(message = "Script execution was stopped.") {
            super(message);
            this.name = "ScriptStoppedError";
        }
    }

    function delay(ms) {
        return new Promise((resolve, reject) => {
            if (!isRunning) { reject(new ScriptStoppedError()); return; }
            currentTimeout = setTimeout(() => { if (isRunning) resolve(); else reject(new ScriptStoppedError()); }, ms);
        });
    }

    // =================================================================================
    // BULK AUTOMATION
    // =================================================================================
    async function waitForElement(selector, options = {}) {
        const { textContent, findAll = false, timeout = 10000 } = options;
        const startTime = Date.now();
        while (Date.now() - startTime < timeout) {
            if (!isRunning) return null;

            const elements = Array.from(document.querySelectorAll(selector));
            if (findAll) {
                if (elements.length > 0) return elements;
            } else {
                const target = textContent
                    ? elements.find(el => el.textContent.trim() === textContent)
                    : elements[0];
                if (target) return target;
            }
            await new Promise(resolve => setTimeout(resolve, 250)); // Use native timeout to avoid cancellation issues here
        }
        console.warn(`[IBR] Timed out waiting for element: ${selector}`);
        return null;
    }

    async function waitForElementToDisappear(selector, options = {}) {
        const { textContent, timeout = 10000 } = options;
        const startTime = Date.now();
        while (Date.now() - startTime < timeout) {
            if (!isRunning) return;
            const elements = Array.from(document.querySelectorAll(selector));
            const target = textContent ? elements.find(el => el.textContent.trim() === textContent) : elements[0];
            if (!target) return; // Element has disappeared
            await new Promise(resolve => setTimeout(resolve, 250));
        }
        console.warn(`[IBR] Timed out waiting for element to disappear: ${selector}`);
    }

    async function runAutomationLoop() {
        const pageConfig = getCurrentPageConfig();
        if (!pageConfig) return;

        try {
            while (isRunning) {
                // --- State Reset: Ensure we are not in selection mode before starting a new cycle ---
                const cancelButton = await waitForElement('span', { textContent: 'Cancel', timeout: 1000 }); // Short timeout, it's just a check
                if (cancelButton) {
                    console.log('[IBR] In selection mode unexpectedly. Resetting UI state...');
                    cancelButton.click();
                    await waitForElementToDisappear('span', { textContent: 'Cancel' }); // Wait for it to disappear
                }

                const selectButton = await waitForElement('span', { textContent: 'Select', timeout: 30000 });
                if (!selectButton) {
                    console.warn('[IBR] "Select" button not found after 30s. Retrying...');
                    await delay(2000);
                    continue;
                }
                selectButton.click();
                await delay(500);

                const maxPosts = getConfig('maxPosts', DEFAULT_MAX_POSTS);
                const clickDelay = getConfig('delayMs', DEFAULT_DELAY_MS);

                // --- Scroll until enough items are loaded ---
                let collectedCheckboxes = [];
                let lastTotalCount = 0;
                let stableCount = 0;
                console.log(`[IBR] Goal: Find up to ${maxPosts} items.`);

                while (isRunning && collectedCheckboxes.length < maxPosts && stableCount < 3) {
                    collectedCheckboxes = Array.from(document.querySelectorAll('div[role="button"][aria-label="Toggle checkbox"]'));

                    if (collectedCheckboxes.length >= maxPosts) {
                        console.log(`[IBR] Found ${collectedCheckboxes.length} items, meeting goal of ${maxPosts}.`);
                        break;
                    }

                    if (collectedCheckboxes.length === lastTotalCount) {
                        stableCount++;
                        console.log(`[IBR] Scroll check ${stableCount}/3: No new items loaded. Found ${collectedCheckboxes.length}.`);
                    } else {
                        stableCount = 0;
                        console.log(`[IBR] Found ${collectedCheckboxes.length}/${maxPosts}. Scrolling for more...`);
                    }
                    lastTotalCount = collectedCheckboxes.length;

                    const scrollableContainer = document.querySelector('div[data-bloks-name="bk.components.Collection"]');
                    if (scrollableContainer) {
                        scrollableContainer.scrollTop = scrollableContainer.scrollHeight - 500;
                    } else {
                        console.warn('[IBR] Could not find scrollable container. Stopping scroll.');
                        break;
                    }
                    await delay(1500);
                }

                if (collectedCheckboxes.length === 0) {
                    alert('[IBR] No items found on the page to process. Automation finished.');
                    break;
                }

                // --- Select the items ---
                const effectiveMax = Math.min(collectedCheckboxes.length, maxPosts);
                console.log(`[IBR] Selecting the first ${effectiveMax} items...`);
                for (let i = 0; i < effectiveMax; i++) {
                    if (!isRunning) throw new ScriptStoppedError();
                    const checkbox = collectedCheckboxes[i];
                    if (checkbox) {
                        checkbox.click();
                    }
                    await delay(clickDelay);
                }

                // --- Perform the final action ---
                const totalSelected = document.querySelectorAll('div[role="button"][aria-label="Toggle checkbox"][aria-checked="true"]').length;
                console.log(`[IBR] Finished selecting ${totalSelected} items. Performing final action...`);
                const initialActionButton = await waitForElement('div[role="button"] span', { textContent: pageConfig.actionText });
                if (initialActionButton) initialActionButton.click();
                await delay(500);
                const confirmActionButton = await waitForElement('button', { textContent: pageConfig.actionText });
                if (confirmActionButton) {
                    confirmActionButton.click();
                    // Wait for the confirmation dialog to disappear before proceeding
                    await waitForElementToDisappear('button', { textContent: pageConfig.actionText });
                }
                // Wait for the main "Select" button to reappear, indicating the UI is ready
                await waitForElement('span', { textContent: 'Select', timeout: 30000 });
            }
        } catch (e) {
            if (e.name === "ScriptStoppedError") {
                console.log('[IBR] Automation stopped by user.');
            } else {
                console.error('[IBR] An unexpected error occurred:', e);
                alert(`[IBR] An error occurred: ${e.message}. Check console for details.`);
            }
        }

        // Once the loop is done, ensure the state is set to "stopped".
        if (isRunning) toggleScript();
    }
    // =================================================================================
    // DUPLICATE "SORT & FILTER" BUTTON STABLY
    // =================================================================================
    function customAction(event, original, duplicate) {
        event.stopPropagation();
        event.preventDefault();
        // First, check if we are on a page where the script can run.
        if (getCurrentPageConfig()) {
            showSettingsDialog();
        } else {
            alert('This feature is only available on the "Your Activity" pages (Likes, Comments, etc.). Please navigate there to use it.');
        }
    }

    function createDuplicate(original) {
        if (!original || original.dataset.dupCreated) return null;

        const clone = original.cloneNode(true);
        clone.dataset.dupCreated = '1';
        clone.id = (original.id || 'sort_filter_orig') + '_dup_' + Date.now();

        const pageConfig = getCurrentPageConfig();
        const buttonText = pageConfig ? pageConfig.headerText : 'Instagram Bulk Remover';
        // Change the button's accessible name and visible text
        clone.setAttribute('aria-label', buttonText);
        clone.querySelector('span').textContent = buttonText;

        clone.style.marginLeft = '10px';
        clone.style.zIndex = '9999';
        clone.style.pointerEvents = 'auto';

        clone.addEventListener('click', function (e) {
            customAction(e, original, clone);
        }, true);

        original.parentNode.insertBefore(clone, original.nextSibling);
        return clone;
    }

    function findAndDuplicateOnce() {
        const original = document.querySelector(TARGET_SELECTOR);
        if (!original) return;
        if (original.dataset.dupCreated) return;
        createDuplicate(original);
        original.dataset.dupCreated = '1';
    }

    const observer = new MutationObserver(() => {
        findAndDuplicateOnce();
    });
    observer.observe(document.documentElement, { childList: true, subtree: true });

    window.addEventListener('load', () => { setTimeout(findAndDuplicateOnce, 800); });
    setTimeout(findAndDuplicateOnce, 2000);

    // =================================================================================
    // STYLES
    // =================================================================================
    GM_addStyle(`
        #${BULK_ACTIONS_SETTINGS_ID}-overlay { position: fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.65); z-index:10000; opacity:0; transition:opacity 150ms ease; }
        #${BULK_ACTIONS_SETTINGS_ID}-overlay.visible { opacity:1; }
        #${BULK_ACTIONS_SETTINGS_ID}-container { position:fixed; top:50%; left:50%; width:500px; max-width:90vw; background:#262626; color:#fafafa; border-radius:24px; z-index:10001; display:flex; flex-direction:column; opacity:0; transform:translate(-50%, -50%) scale(0.95); max-height:90vh; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size:14px; animation:ig-dialog-bounce-in 300ms ease-out forwards; }
        #${BULK_ACTIONS_SETTINGS_ID}-container.visible { opacity:1; transform:translate(-50%, -50%) scale(1); }
        @keyframes ig-dialog-bounce-in {0%{opacity:0;transform:translate(-50%,-50%) scale(1.15);}50%{opacity:1;transform:translate(-50%,-50%) scale(1.03);}80%{transform:translate(-50%,-50%) scale(0.99);}100%{transform:translate(-50%,-50%) scale(1);}}
        .native-popup-header { display:flex; align-items:center; justify-content:center; position:relative; padding:12px 16px; border-bottom:1px solid #363636; }
        .native-popup-title { font-size:16px; font-weight:700; }
        .native-popup-close { position:absolute; left:0; top:0; bottom:0; display:flex; align-items:center; padding:0 16px; cursor:pointer; }
        .native-popup-content { padding:16px; overflow-y:auto; display: flex; flex-direction: column; gap: 16px; }
        .native-control-group { margin-bottom:0; }
        .native-slider-label { font-size:14px; font-weight:400; margin-bottom:16px; display:block; color:#a8a8a8; transition: color 200ms ease; }
        .native-slider-container { display:flex; align-items:center; gap:16px; }
        .native-slider-value { font-size:14px; font-variant-numeric:tabular-nums; color:#f5f5f5; }
        #${BULK_ACTIONS_SETTINGS_ID}-container input[type="range"] { -webkit-appearance:none; appearance:none; width:100%; height:4px; border-radius:2px; cursor:pointer; background:#363636; }
        #${BULK_ACTIONS_SETTINGS_ID}-container input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:20px; height:20px; border-radius:50%; border:none; background:#f5f5f5; box-shadow:0 0 2px rgba(0,0,0,0.5);}
        #${BULK_ACTIONS_SETTINGS_ID}-container #run-stop-button { width:100%; min-height:44px; font-family:inherit; font-size:14px; font-weight:700; border:none; border-radius:8px; cursor:pointer; transition:background-color 0.2s; margin-top: 10px; }
        #${BULK_ACTIONS_SETTINGS_ID}-container #run-stop-button[data-running="false"] { background-color:#0095f6; color:#fff; }
        #${BULK_ACTIONS_SETTINGS_ID}-container #run-stop-button[data-running="true"] { background-color:#ed4956; color:#fff; }
    `);

    // This init function is no longer needed as the code is now self-contained and runs on load.
    // The observer and initial calls handle the button injection.
    function init() {
        console.log('[IBR] Script initializing...');
        // Reset running state on script load/reload
        if (getConfig(IS_RUNNING_KEY, false)) {
            setConfig(IS_RUNNING_KEY, false);
        }
        isRunning = false;
    }

    init();

})();