X-Fwilter

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         X-Fwilter
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Filter away self-reposts, 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
// @grant        GM_addStyle
// ==/UserScript==

(function() {
    'use strict';

    // --- CONSTANTS & CONFIG ---

    const FILTERS = {
        SelfPost: 'self',
        Video: 'video',
        Image: 'image',
        Text: 'text'
    };

    const ICONS = {
        retweet: "https://raw.githubusercontent.com/TheFeThrone/X-Fwilter/refs/heads/main/icons/selfretweet.svg",
        video: "https://raw.githubusercontent.com/TheFeThrone/X-Fwilter/refs/heads/main/icons/film.svg",
        image: "https://raw.githubusercontent.com/TheFeThrone/X-Fwilter/refs/heads/main/icons/image.svg",
        text: "https://raw.githubusercontent.com/TheFeThrone/X-Fwilter/refs/heads/main/icons/book.svg"
    };

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

    let dynamicStyleElement = null;

    // --- STYLE MANAGEMENT ---

    function setupStyles() {
        GM_addStyle(`
            .fwilter-wrapper {
                display: flex;
                flex-direction: column;
                align-items: center;
                position: sticky;
            }
            #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::before:hover {
                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);
    }

    function updateFilterStyles() {
        let cssToApply = '';
        for (const [checkboxId, filterType] of Object.entries(FILTERS)) {
            const checkbox = document.getElementById(checkboxId);
            if (checkbox && checkbox.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,
            });
        });
    }

    /**
     * Finds tweet that is self-repost.
     * @param {HTMLElement} tweet - The tweet element.
     * @returns {boolean} - True if the tweet was hidden.
     */
    function isSelfRepost(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.includes(poster)) {
            return true;
        }
        return false;
    }

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

        if (isSelfRepost(tweet)) {
            types.push('self');
        }

        const tweetMedia = tweet.querySelector('div[data-testid="tweetPhoto"]');
        if (tweetMedia) {
            const hasVideo = tweetMedia.querySelector('video, [data-testid="previewInterstitial"]');
            const hasImage = tweetMedia.querySelector('img:not([src*="profile_images"]');

            if (hasVideo) {
                types.push('video');
            } else if (hasImage) {
                types.push('image');
            }
        } else {
            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 = Array.from(document.querySelectorAll('[data-testid="cellInnerDiv"]'));
            if(tweets.length==0) {
                return;
            }
            //showStatus("Processing existing tweets");
            for (const tweet of tweets) {
                await processTweet(tweet);
            }
        } else {
            return;
        }
    }

    async function createUI() {
        const timeline = await getTimeline();
        let uiBase = document.querySelector('[data-testid="primaryColumn"] .css-175oi2r.r-1awozwy.r-18u37iz.r-h3s6tt.r-1777fci.r-f8sm7e.r-13qz1uu.r-gu64tb');
        if (!uiBase || !uiBase.childNodes) {
            // Profile case
            const timelineTabs = document.getElementsByClassName("TimelineTabs");
            if (timelineTabs.length > 0) uiBase = timelineTabs[0];
        }
        // 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);
        uiBase.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 ---

    function init() {
        createUI();
        setupStyles();
        updateFilterStyles();
        setTimeout( async function() {
            const timeline = await getTimeline();
            if (timeline) {
                feedObserver.observe(document.body, { childList: true, subtree: true });
                await processExisting();
            }
        }, 1000);
         // Run once on startup to apply initial filter state
    }


    // --- OBSERVERS ---


    const tweetObserver = new IntersectionObserver(async (entries) => {
        for (const entry of entries) {
            if (!entry.isIntersecting) continue;
            const tweet = entry.target;
            await processTweet(tweet);
            tweetObserver.unobserve(tweet);
        }
    }, { root: document, 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"]');
                    tweets.forEach( async tweet => {
                        if(!tweet.dataset.uncropid) {
                            tweetObserver.observe(tweet);
                        }
                    });
                }
            }
        }
    });

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


    init();
})();