X One Click Button

Perform actions such as blocking, muting, and unfollowing with a single click on X (Twitter) posts and following/follower lists.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name           X One Click Button
// @name:ja        X One Click Button
// @namespace      https://github.com/hakhatz2486/x-one-click-button
// @version        1.0.1
// @description    Perform actions such as blocking, muting, and unfollowing with a single click on X (Twitter) posts and following/follower lists.
// @description:ja X(Twitter)のポストやフォロー中/フォロワーの一覧に対し、ブロック、ミュート、フォロー解除等の操作をワンクリックで実行できます。
// @author         hakhatz2486
// @homepageURL    https://greasyfork.org/ja/scripts/587690-x-one-click-button
// @supportURL     https://github.com/hakhatz2486/x-one-click-button/issues
// @license        MIT
// @match          https://x.com/*
// @match          https://twitter.com/*
// @icon           https://www.google.com/s2/favicons?sz=64&domain=x.com
// @grant          none
// @run-at         document-end
// ==/UserScript==

(function () {
    "use strict";

    function isElementVisible(element) {
        return element.isConnected && element.getClientRects().length > 0;
    }

    function getVisibleElements(selector) {
        return new Set(
            [...document.querySelectorAll(selector)].filter(isElementVisible),
        );
    }

    // 操作前から表示されていた要素を除外し、新しく表示された要素を待機する。
    async function waitForNewElement(selector, excluded, timeout = 2000) {
        return new Promise((resolve) => {
            let timeoutId;
            const findElement = () =>
                [...document.querySelectorAll(selector)].find(
                    (element) =>
                        !excluded.has(element) && isElementVisible(element),
                );
            const observer = new MutationObserver(() => {
                const element = findElement();
                if (element) finish(element);
            });
            const finish = (element) => {
                observer.disconnect();
                clearTimeout(timeoutId);
                resolve(element);
            };

            const element = findElement();
            if (element) {
                finish(element);
                return;
            }

            observer.observe(document.body, {
                attributes: true,
                attributeFilter: ["class", "style", "aria-hidden"],
                childList: true,
                subtree: true,
            });

            timeoutId = setTimeout(() => finish(null), timeout);
        });
    }

    // アクションごとのキーワードと設定
    const ACTION_CONFIG = {
        block: {
            keywords: ["ブロック", "block"],
            excludedKeywords: ["ブロック解除", "ブロックを解除", "unblock"],
            needsConfirm: true,
            hoverColor: "rgb(249, 24, 128)", // ピンク/赤系
            hoverBg: "rgba(249, 24, 128, 0.1)",
        },
        mute: {
            keywords: ["ミュート", "mute"],
            excludedKeywords: ["ミュート解除", "ミュートを解除", "unmute"],
            needsConfirm: false,
            hoverColor: "rgb(29, 155, 240)", // 青系
            hoverBg: "rgba(29, 155, 240, 0.1)",
        },
        unfollow: {
            keywords: ["フォロー解除", "unfollow", "フォローを解除"],
            needsConfirm: true,
            hoverColor: "rgb(255, 173, 31)", // オレンジ系
            hoverBg: "rgba(255, 173, 31, 0.1)",
        },
        removeFollower: {
            keywords: ["フォロワーを削除", "remove this follower"],
            needsConfirm: true,
            hoverColor: "rgb(255, 173, 31)", // オレンジ系
            hoverBg: "rgba(255, 173, 31, 0.1)",
        },
    };

    let actionInProgress = false;

    function matchesAction(text, config) {
        const normalizedText = text.toLocaleLowerCase();
        const isExcluded = config.excludedKeywords?.some((keyword) =>
            normalizedText.includes(keyword.toLocaleLowerCase()),
        );
        if (isExcluded) return false;

        return config.keywords.some((keyword) =>
            normalizedText.includes(keyword.toLocaleLowerCase()),
        );
    }

    // UIのクリックを自動化してアクションを実行する共通関数
    async function executeAction(menuBtn, actionType) {
        const config = ACTION_CONFIG[actionType];
        try {
            // 1. 三点リーダー(もっと見る)をクリック
            const existingMenus = getVisibleElements('[role="menu"]');
            menuBtn.click();

            // 2. 今回のクリックで開いたドロップダウンメニューを待機
            const menu = await waitForNewElement(
                '[role="menu"]',
                existingMenus,
            );
            if (!menu) {
                document.body.click();
                return false;
            }

            // メニュー項目の描画を少し待つ
            await new Promise((resolve) => setTimeout(resolve, 100));

            // 対象の項目を探してクリック
            let targetMenuItem = null;
            const items = menu.querySelectorAll('[role="menuitem"]');
            for (const item of items) {
                if (matchesAction(item.textContent, config)) {
                    targetMenuItem = item;
                    break;
                }
            }

            if (!targetMenuItem) {
                document.body.click(); // メニューに項目が存在しない場合は閉じる
                return false;
            }

            // 3. 必要な場合は今回表示された確認ダイアログだけを操作する
            if (config.needsConfirm) {
                const confirmSelector =
                    '[data-testid="confirmationSheetConfirm"]';
                const existingConfirmButtons =
                    getVisibleElements(confirmSelector);
                targetMenuItem.click();
                const confirmButton = await waitForNewElement(
                    confirmSelector,
                    existingConfirmButtons,
                );
                if (!confirmButton) {
                    document.body.click(); // ダイアログを閉じる
                    return false;
                }
                confirmButton.click();
            } else {
                targetMenuItem.click();
                // 確認が不要な場合(ミュートなど)は処理完了を少し待つ
                await new Promise((resolve) => setTimeout(resolve, 300));
            }

            return true;
        } catch (e) {
            console.error(`DOM click ${actionType} failed:`, e);
            document.body.click(); // エラー時にUIをリセット
            return false;
        }
    }

    async function executeCurrentMenuAction(container, selector, actionType) {
        const menuBtn = container.querySelector(selector);
        if (!menuBtn) return false;
        return executeAction(menuBtn, actionType);
    }

    // フォロー中一覧の標準ボタンを使ってフォロー解除する
    async function executeUnfollowButton(cell) {
        try {
            // Reactの再描画後も現在の標準ボタンを使う。
            const unfollowBtn = cell.querySelector(
                'button[data-testid$="-unfollow"]',
            );
            if (!unfollowBtn) return false;

            const confirmSelector = '[data-testid="confirmationSheetConfirm"]';
            const existingConfirmButtons = getVisibleElements(confirmSelector);
            unfollowBtn.click();

            const confirmButton = await waitForNewElement(
                confirmSelector,
                existingConfirmButtons,
            );
            if (!confirmButton) {
                document.body.click();
                return false;
            }

            confirmButton.click();
            return true;
        } catch (e) {
            console.error("DOM click unfollow failed:", e);
            document.body.click();
            return false;
        }
    }

    // カスタムボタンを生成するヘルパー関数
    function createActionButton(text, actionType, onClick) {
        const config = ACTION_CONFIG[actionType];
        const btn = document.createElement("button");
        btn.type = "button";
        btn.className = `custom-btn-${actionType}`;
        btn.textContent = text;

        const baseStyle = `
            background-color: transparent;
            border: 1px solid currentColor;
            border-radius: 9999px;
            color: inherit;
            opacity: 0.6;
            font-size: 13px;
            font-weight: bold;
            cursor: pointer;
            padding: 0 12px;
            height: 30px;
            margin-right: 8px;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            transition: all 0.2s;
            user-select: none;
            white-space: nowrap;
        `;
        btn.style.cssText = baseStyle;

        btn.onmouseover = () => {
            if (btn.disabled) return;
            btn.style.opacity = "1";
            btn.style.backgroundColor = config.hoverBg;
            btn.style.color = config.hoverColor;
            btn.style.borderColor = config.hoverColor;
        };

        btn.onmouseout = () => {
            if (btn.textContent === text) {
                // 配置先で上書きした余白などを維持し、ホバー項目だけ戻す。
                btn.style.opacity = "0.6";
                btn.style.backgroundColor = "transparent";
                btn.style.color = "inherit";
                btn.style.borderColor = "currentColor";
            }
        };

        btn.onclick = async (e) => {
            e.preventDefault();
            e.stopPropagation();

            if (actionInProgress) return;
            actionInProgress = true;

            const parent = btn.parentElement;
            const actionButtons = document.querySelectorAll(
                'button[class^="custom-btn-"]',
            );
            actionButtons.forEach((button) => (button.disabled = true));

            btn.textContent = "Processing...";
            btn.style.opacity = "0.5";

            let success = false;
            let targetEl = null;
            try {
                success = await onClick();

                if (success) {
                    btn.textContent = "Done";
                    btn.style.opacity = "1";
                    btn.style.color = config.hoverColor;
                    btn.style.borderColor = config.hoverColor;
                    btn.style.backgroundColor = config.hoverBg;

                    // 処理対象のコンテナ(ツイートまたはユーザーセル)を半透明に
                    targetEl = parent;
                    while (
                        targetEl &&
                        !targetEl.getAttribute("data-custom-action-added")
                    ) {
                        targetEl = targetEl.parentElement;
                    }
                    if (targetEl) {
                        targetEl.style.opacity = "0.3";
                        targetEl.style.pointerEvents = "none";
                    }
                } else {
                    btn.textContent = "Failed";
                    btn.style.opacity = "1";
                    btn.style.color = "red";
                    btn.style.borderColor = "red";
                }
            } catch (error) {
                console.error(`Custom ${actionType} action failed:`, error);
                btn.textContent = "Failed";
                btn.style.opacity = "1";
                btn.style.color = "red";
                btn.style.borderColor = "red";
            } finally {
                actionInProgress = false;
                document
                    .querySelectorAll('button[class^="custom-btn-"]')
                    .forEach((button) => {
                        const isCompletedTarget =
                            success && targetEl?.contains(button);
                        button.disabled = Boolean(isCompletedTarget);
                    });
            }
        };

        return btn;
    }

    // 各要素にボタンを追加する処理
    function addActionButtons() {
        // 1. タイムライン等のツイートに対する処理
        const tweets = document.querySelectorAll(
            'article[data-testid="tweet"]:not([data-custom-action-added])',
        );
        tweets.forEach((tweet) => {
            const caret = tweet.querySelector('[data-testid="caret"]');
            if (!caret) return;

            const targetContainer = caret.parentElement;
            if (!targetContainer) return;

            tweet.setAttribute("data-custom-action-added", "true");

            const blockBtn = createActionButton("Block", "block", () =>
                executeCurrentMenuAction(
                    tweet,
                    '[data-testid="caret"]',
                    "block",
                ),
            );
            const muteBtn = createActionButton("Mute", "mute", () =>
                executeCurrentMenuAction(
                    tweet,
                    '[data-testid="caret"]',
                    "mute",
                ),
            );

            targetContainer.insertBefore(blockBtn, caret);
            targetContainer.insertBefore(muteBtn, blockBtn);
        });

        // 2. フォロー中・フォロワー一覧のユーザーセルに対する処理
        const currentPage = location.pathname.split("/").filter(Boolean).at(-1);
        const isFollowingPage = currentPage === "following";
        const isFollowersPage = currentPage === "followers";
        const userCells = document.querySelectorAll('[data-testid="UserCell"]');
        userCells.forEach((cell) => {
            const existingUnfollowBtn = cell.querySelector(
                ".custom-btn-unfollow",
            );
            const existingRemoveFollowerBtn = cell.querySelector(
                ".custom-btn-removeFollower",
            );
            const existingBlockBtn = cell.querySelector(".custom-btn-block");

            if (isFollowingPage) {
                // SPA遷移でフォロワー一覧用ボタンが残っていた場合は置き換える。
                if (
                    existingUnfollowBtn &&
                    !existingRemoveFollowerBtn &&
                    !existingBlockBtn
                ) {
                    return;
                }
                existingUnfollowBtn?.remove();
                existingRemoveFollowerBtn?.remove();
                existingBlockBtn?.remove();

                // フォロー中一覧には三点メニューがなく、標準の解除ボタンがある。
                const nativeUnfollowBtn = cell.querySelector(
                    'button[data-testid$="-unfollow"]',
                );
                if (!nativeUnfollowBtn) return;

                const targetContainer = nativeUnfollowBtn.parentElement;
                if (!targetContainer) return;

                cell.setAttribute("data-custom-action-added", "true");

                const unfollowBtn = createActionButton(
                    "Unfollow",
                    "unfollow",
                    () => executeUnfollowButton(cell),
                );

                targetContainer.style.setProperty(
                    "display",
                    "inline-flex",
                    "important",
                );
                targetContainer.style.setProperty(
                    "flex-direction",
                    "row",
                    "important",
                );
                targetContainer.style.setProperty(
                    "flex-wrap",
                    "nowrap",
                    "important",
                );
                targetContainer.style.setProperty(
                    "align-items",
                    "center",
                    "important",
                );
                targetContainer.style.setProperty("gap", "8px", "important");
                unfollowBtn.style.marginRight = "0";
                targetContainer.appendChild(unfollowBtn);
                return;
            }

            // フォロワー一覧では「フォロワーを削除」だけを対象にする。
            const expectedActionBtn = isFollowersPage
                ? existingRemoveFollowerBtn
                : existingUnfollowBtn;
            if (expectedActionBtn && existingBlockBtn) return;

            const oldActionButtons = [
                existingUnfollowBtn,
                existingRemoveFollowerBtn,
            ].filter(Boolean);
            const oldContainers = new Set(
                oldActionButtons.map((button) => button.parentElement),
            );
            oldActionButtons.forEach((button) => button.remove());
            oldContainers.forEach((container) => {
                container?.style.removeProperty("display");
                container?.style.removeProperty("flex-direction");
                container?.style.removeProperty("flex-wrap");
                container?.style.removeProperty("align-items");
                container?.style.removeProperty("gap");
            });
            existingBlockBtn?.remove();

            const menuBtn = cell.querySelector('[aria-haspopup="menu"]');
            if (!menuBtn) return;

            const targetContainer = menuBtn.parentElement;
            if (!targetContainer) return;

            cell.setAttribute("data-custom-action-added", "true");

            const blockBtn = createActionButton("Block", "block", () =>
                executeCurrentMenuAction(
                    cell,
                    '[aria-haspopup="menu"]',
                    "block",
                ),
            );
            const actionType = isFollowersPage ? "removeFollower" : "unfollow";
            const unfollowBtn = createActionButton("Unfollow", actionType, () =>
                executeCurrentMenuAction(
                    cell,
                    '[aria-haspopup="menu"]',
                    actionType,
                ),
            );

            targetContainer.insertBefore(blockBtn, menuBtn);
            targetContainer.insertBefore(unfollowBtn, blockBtn);
        });
    }

    let addButtonsScheduled = false;
    function scheduleAddActionButtons() {
        if (addButtonsScheduled) return;
        addButtonsScheduled = true;
        requestAnimationFrame(() => {
            addButtonsScheduled = false;
            addActionButtons();
        });
    }

    const observer = new MutationObserver((mutations) => {
        if (mutations.some((mutation) => mutation.addedNodes.length > 0)) {
            scheduleAddActionButtons();
        }
    });

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

    // 初期実行
    addActionButtons();
})();