X-Fwilter

Filter away retweets, self-retweets, videos, images, texts, ..

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         X-Fwilter
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Filter away retweets, self-retweets, videos, images, texts, ..
// @author       TheFeThrone
// @match        https://x.com/*
// @exclude      *://x.com/i/*
// @exclude      *://x.com/hashtag/*
// @exclude      *://x.com/notifications/*
// @exclude      *://x.com/settings/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=x.com
// @run-at       document-start
// @resource retweet https://raw.githubusercontent.com/TheFeThrone/X-Fwilter/refs/heads/main/icons/retweet.svg
// @resource selftweet https://raw.githubusercontent.com/TheFeThrone/X-Fwilter/refs/heads/main/icons/selfretweet.svg
// @resource video https://raw.githubusercontent.com/TheFeThrone/X-Fwilter/refs/heads/main/icons/film.svg
// @resource image https://raw.githubusercontent.com/TheFeThrone/X-Fwilter/refs/heads/main/icons/image.svg
// @resource text https://raw.githubusercontent.com/TheFeThrone/X-Fwilter/refs/heads/main/icons/book.svg
// @grant        GM_addStyle
// @grant        GM_getResourceURL
// ==/UserScript==

(async function() {
    'use strict';

    // --- CONSTANTS & CONFIG ---

    const FILTERS = {
        Retweet: 'retweet',
        Selftweet: 'selftweet',
        Video: 'video',
        Image: 'image',
        Text: 'text'
    };

    const ICONS = {
        retweet: GM_getResourceURL("retweet"),
        selftweet: GM_getResourceURL("selftweet"),
        video: GM_getResourceURL("video"),
        image: GM_getResourceURL("image"),
        text: GM_getResourceURL("text")
    };

    const FILTER_ICON_MAP = {
        Retweet: 'retweet',
        Selftweet: 'selftweet',
        Video: 'video',
        Image: 'image',
        Text: 'text'
    };

    // --- STYLE MANAGEMENT ---
    let stylesInjected = false;

    function setupStyles() {
        if (stylesInjected) return;
        stylesInjected = true;
        GM_addStyle(`
            .fwilter-wrapper {
                display: flex;
                flex-direction: column;
                align-items: center;
                // position: sticky;
                // display: grid;
                position: fixed;
                gap: 0.5em;
                left: 0;
                top: 5em;
            }
            #fwilter {
                display: flex;
            }
            #fwilter > div { margin: 0 8px; position: relative; } /* Added position relative */
            #fwilter input[type="checkbox"] { display: none; }

            #fwilter input[type="checkbox"] + label {
                display: flex;
                align-items: center;
                justify-content: center;
                width: 32px;
                height: 32px;
                border: 1px solid #cfd9de;
                border-radius: 50%;
                cursor: pointer;
                transition: background-color 0.2s ease;
            }

            #fwilter input[type="checkbox"] + label::before {
                content: '';
                width: 20px;
                height: 20px;
                background-color: #c8a2c8;
                mask-image: var(--fwilter-visible-svg);
                mask-size: contain;
                mask-position: center;
                mask-repeat: no-repeat;
            }
            #fwilter input[type="checkbox"]:checked + label::before {
                background-color: #E0245E;
            }

            #fwilter input[type="checkbox"] + label:hover::before {
                background-color: violet;
            }
        `);

        // Inject icon definitions into the page
        let iconVariablesCSS = ':root {\n';
        for (const key in ICONS) {
            iconVariablesCSS += `    --icon-${key}-visible: url("${ICONS[key]}");\n`;
        }
        iconVariablesCSS += '}';
        const iconStyleElement = document.createElement('style');
        iconStyleElement.id = 'fwilter-icon-definitions';
        iconStyleElement.textContent = iconVariablesCSS;
        document.head.appendChild(iconStyleElement);
    }

    let dynamicStyleElement = null;
    function updateFilterStyles() {
        let cssToApply = '';

        const wrapper = document.querySelector('#fwilter');
        const videoChecked = document.getElementById('Video')?.checked;
        const imageChecked = document.getElementById('Image')?.checked;

        for (const [checkboxId, filterType] of Object.entries(FILTERS)) {
            const checkbox = document.getElementById(checkboxId);
            if (!checkbox || !checkbox.checked) continue;

            // Special handling for video
            if (filterType === 'video' && !imageChecked) {
                cssToApply += `
                [data-testid="cellInnerDiv"][fwilter-types~="video"]:not([fwilter-types~="image"]) { display: none; }\n`;
                continue;
            }

            // Special handling for image
            if (filterType === 'image' && !videoChecked) {
                cssToApply += `
                [data-testid="cellInnerDiv"][fwilter-types~="image"]:not([fwilter-types~="video"]) { display: none; }\n`;
                continue;
            }

            // Default behaviour (retweet, selftweet, text, and also
            // video/image when both are checked)
            cssToApply += `
            [data-testid="cellInnerDiv"][fwilter-types~="${filterType}"]
            { display: none; }\n`;
        }
        if (!dynamicStyleElement) {
            dynamicStyleElement = document.createElement('style');
            dynamicStyleElement.id = 'fwilter-dynamic-rules';
            document.head.appendChild(dynamicStyleElement);
        }
        dynamicStyleElement.textContent = cssToApply;
    }


    // --- UTILITY FUNCTIONS ---

    /**
     * Waits for a specific element to appear in the DOM.
     * @param {string} selector - The CSS selector for the element.
     * @returns {Promise<Element>}
     */
    function waitForElement(selector, base=document) {
        return new Promise(resolve => {
            if (base.querySelector(selector)) {
                return resolve(base.querySelector(selector));
            }

            const observer = new MutationObserver(() => {
                if (base.querySelector(selector)) {
                    resolve(base.querySelector(selector));
                    observer.disconnect();
                }
            });

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

    async function waitForMedia(tweetMedia) {
        return new Promise(resolve => {
            if (tweetMedia.querySelector('video, img')) return resolve();

            const observer = new MutationObserver(() => {
                if (tweetMedia.querySelector('video, img')) {
                    observer.disconnect();
                    resolve();
                }
            });

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

    /**
     * Finds tweet that is self-repost.
     * @param {HTMLElement} tweet - The tweet element.
     * @returns {boolean} - True if the tweet was hidden.
     */
    function isSelftweet(tweet) {
        const poster = tweet.querySelector('[data-testid="User-Name"] span span')?.textContent;
        const reposter = tweet.querySelector('[data-testid="socialContext"] span')?.textContent;
        if (poster && reposter && reposter.trim() === poster.trim()) {
            return true;
        }
        return false;
    }
    function isRetweet(tweet) {
        const retweet = tweet.querySelector('[data-testid="socialContext"] span')?.textContent;
        if (retweet) {
            return true;
        }
        return false;
    }

    // --- TWEET PROCESSING ---
    /**
     * Main processing function for each tweet.
     * @param {HTMLElement} tweet - The tweet element.
     */
    async function processTweet(tweet) {
        const types = [];

        if (isSelftweet(tweet)) {
            types.push('selftweet', 'retweet');
        } else if (isRetweet(tweet)) {
            types.push('retweet');
        }

        const tweetText = tweet.querySelector('div[data-testid="tweetText"]');
        const tweetMedia = tweet.querySelector('div[data-testid="tweetPhoto"]').closest('[aria-labelledby]');
        if(tweetText && !tweetMedia) {
            types.push('text');
            tweet.setAttribute('fwilter-types', types.join(' '));
            return;
        }

        if (tweetMedia) await waitForMedia(tweetMedia);

        const hasVideo = tweetMedia?.querySelector('video, [data-testid="previewInterstitial"], [alt="Embedded video"]');
        const hasImage = tweetMedia?.querySelector('img:not([src*="profile_images"], [alt="Embedded video"])');

        if (hasVideo) types.push('video');
        if (hasImage) types.push('image');
        if (!hasVideo && !hasImage) types.push('text');

        tweet.setAttribute('fwilter-types', types.join(' '));
    }

    async function processExisting(){
        const timeline = await getTimeline();
        const first = await waitForElement('[data-testid="cellInnerDiv"]', timeline);
        if (first) {
            const tweets = document.querySelectorAll('[data-testid="cellInnerDiv"]:not([fwilter-types])');

            if(tweets.length==0) return;

            for (const tweet of tweets) {
                await processTweet(tweet);
            }
        } else {
            return;
        }
    }

    async function createUI() {
        const timeline = await getTimeline();
        const uiBase = await waitForElement('.TimelineTabs, [data-testid="primaryColumn"] .css-175oi2r.r-1awozwy.r-18u37iz.r-h3s6tt.r-1777fci.r-f8sm7e.r-13qz1uu.r-gu64tb');
        const finalBase = document.querySelector('[role="banner"]')
        if (!uiBase) console.log("not found uiBase");
        const existingUI = finalBase.querySelector('div#fwilter');
        if (existingUI) {
            console.log("existed UI");
            return;
        }
        // 1. Create a new wrapper for UI
        const flexWrapper = document.createElement('div');
        flexWrapper.className = 'fwilter-wrapper';

        // 2. Create the container for the filter buttons
        const fwilterContainer = document.createElement('div');
        fwilterContainer.id = 'fwilter';
        for (const purpose in FILTERS) {
            createCheckbox(purpose, fwilterContainer);
        }

        flexWrapper.appendChild(fwilterContainer);
        finalBase.appendChild(flexWrapper);

    }

    function createCheckbox(purpose, fwilterContainer) {
        const checkbox = document.createElement("input");
        checkbox.type = "checkbox";
        checkbox.id = purpose;
        checkbox.addEventListener('change', updateFilterStyles);

        const label = document.createElement("label");
        label.htmlFor = purpose;
        label.title = purpose;

        const iconKey = FILTER_ICON_MAP[purpose];
        if (iconKey) {
            label.style.setProperty('--fwilter-visible-svg', `var(--icon-${iconKey}-visible)`);
        }

        const wrapper = document.createElement("div");
        wrapper.appendChild(checkbox);
        wrapper.appendChild(label);
        fwilterContainer.appendChild(wrapper);
    }
    // --- FILTERING LOGIC ---

    async function init() {
        setupStyles();
        updateFilterStyles();
        await createUI();
        await start();
    }
    const runInit = () => { init(); };

    async function start(){
        clearTimers();
        disconnectObservers();

        initTimeoutId = setTimeout( async () => {
            const timeline = await getTimeline();
            if (timeline) {
                feedObserver.observe(document.body, { childList: true, subtree: true });
                unprocessedIntervalId = setInterval(processExisting, 500);
            }
        }, 1000);
    }

    let unprocessedIntervalId = null;
    let initTimeoutId = null;
    let observedTweets = [];

    function clearTimers(){
        if (unprocessedIntervalId) clearInterval(unprocessedIntervalId);
        if (initTimeoutId) clearTimeout(initTimeoutId);
    }

    function disconnectObservers(){
        feedObserver.disconnect();
        if (observedTweets.length != 0) {
            observedTweets.forEach( tweet => unobserveTweet(tweet) );
        }
        tweetObserver.disconnect();
    }

    function unobserveTweet(tweet){
        tweetObserver.unobserve(tweet);
        const index = observedTweets.indexOf(tweet);
        if (index > -1) { observedTweets.splice(index, 1); }
    }
    function observeTweet(tweet){
        observedTweets.push(tweet);
        tweetObserver.observe(tweet);
    }

    // X-Navigation Override
    function overrideXNav(){
        const pushStateOrig = history.pushState;
        const replaceStateOrig = history.replaceState;
        history.pushState = function(...args) {
            pushStateOrig.apply(this, args);
            console.log("pushState");
            runInit();
        }
        history.replaceState = function(...args) {
            replaceStateOrig.apply(this, args);
            console.log("replaceState");
            runInit();
        }
        window.addEventListener('popstate', runInit);
    }

    // --- OBSERVERS ---
    const tweetObserver = new IntersectionObserver(async (entries) => {
        for (const entry of entries) {
            if (!entry.isIntersecting) continue;
            const tweet = entry.target;
            await processTweet(tweet);
            unobserveTweet(tweet);
        }
    }, { root: null, rootMargin: "5px 0px" });

    const feedObserver = new MutationObserver(async (mutations) => {
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (node.nodeType === 1) {
                    const tweets =
                          node.matches('[data-testid="cellInnerDiv"]') ? [node] :
                          node.querySelectorAll('[data-testid="cellInnerDiv"]');

                    if(tweets.length==0) continue;
                    for (const tweet of tweets) {
                        observeTweet(tweet);
                    }
                }
            }
        }
    });

    async function getTimeline(){
        return await waitForElement('[aria-label*="Timeline"]');
    }

    overrideXNav();

    await init();
})();