FB Mobile - Clean my feeds

Removes Sponsored and Suggested posts from Facebook mobile chromium/react version

// ==UserScript==
// @name        FB Mobile - Clean my feeds
// @namespace   Violentmonkey Scripts
// @match       https://m.facebook.com/*
// @match       https://www.facebook.com/*
// @version     0.41
// @icon        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAbwAAAG8B8aLcQwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAHZSURBVDiNnZFLSFRxFMa/c1/jjIzYpGEjxFQUCC5a9BKJIAtRzEXEFaJFZXRrIQMtk3a1lWo3iwqkTS0kZyGCA4VNFNEmWwU9MIoiscZp7jzuvf9zWogXogS9Z3fO4fv4feeQiCBKjY8M9Nca3lUtkhqAUnwNoPcUheC63b+z5qm3nmelIxGwkMMir+/MzJSNzYodZ7/ZolKXADoDAJsmSJXahpXiXxPThdlIBlCSFUh+rd1wBNvuttLu1sOGae7zYjy4Nt8QgXpoXbzf9/HVYNfi3O+KK5XP5V3rEti2rde3pHvyuVtFAMB8/JjWJLlEU0M7nlnE0e1fjGVqPgVg4b8E0rHnHoSeDY1mx/CCUiIyiVZdQ8YE7bVgdpCWCqrj6xIQ0Rtm/qlB3okXywHoDJcxAnWa0OPtpb8M8nPP06V6tVD3/Mqj2zcOApjA0/g5AU6HYl7llcAANP4WHnH6SfEQ65hPJuJdvh8cuDs165y8nO1bqiZb4KoyVhhYVoDLqxEDAwT+EBqwwAGwm4jQmmyGF/g3Y3pi+MLU2U9UCjKUwCga/BUmAT8CiDIAnRfCyI8LxSNCeABgh1uro+zWlq7YQ9v++WXe7GWDziu/bcS0+AQGvr8EgD/aK7uaswjePgAAAABJRU5ErkJggg==
// @run-at      document-end
// @author      https://github.com/webdevsk
// @description Removes Sponsored and Suggested posts from Facebook mobile chromium/react version
// @license     MIT
// @grant       GM_addStyle
// ==/UserScript==

// Some Things to note here
// This is a React site. Only #screen-root is shipped with the HTML. Everything inside is populated using JS.
// That makes it the perfect element to "observe".
// In order to reduce device memory usage, they remove/compress/disable posts that are far from the current scroll position.
// As they lose their organic Height, facebook uses (2) filler elements to make up for that empty space.
// As posts get constantly added/removed by themselves, you see some jitters while scrolling.
// We are removing posts ourselves. So the jitter happens way more often **SORRY**
// As the posts get removed, the filler elements height need to be adjusted as well. Thats where the jitter happens.
// As filler height goes from say 5000px to 500px in a second when we update it ourselves.
// After scrolling for a while, they just keep spamming suggested posts and ads. So you will often see the "Loading more posts" element.

const devMode = false
const showPlaceholder = true


// Make sure this is the React-Mobile version of facebook
if (!document.documentElement.classList.contains("ssr")) return

// React root
const root = document.querySelector('#screen-root')
if (!root) return

////////////////////////////////////////////////////////////////////////////////
////////////////////                   Classes           ////////////////////////
////////////////////////////////////////////////////////////////////////////////

class Spinner {
    constructor() {
        this.elm = document.createElement("div")
        this.elm.id = "block-counter"
        Object.assign(this.elm.style, { position: "fixed", top: "20px", left: "16px", pointerEvents: "none", zIndex: 100 })
        this.elm.innerHTML = `<div class="spinner small animated"></div>`
        document.body.appendChild(this.elm)
    }

    show() {
        this.elm.style.display = "block"
    }

    hide() {
        this.elm.style.display = "none"
    }
}

class BlockCounter {
    whitelisted = 0
    blacklisted = 0

    constructor() {
        if (!devMode) return
        this.elm = document.createElement("div")
        document.body.appendChild(this.elm)
        Object.assign(this.elm.style, { position: "fixed", top: 0, right: 0, padding: ".5rem 1rem", background: "#323436", borderRadius: ".2rem", display: "flex", flexFlow: "row wrap", zIndex: 99, color: "#ddd", gap: ".5rem", fontSize: ".8rem", pointerEvents: "none", })
        this.render()
    }

    render() {
        if (devMode) this.elm.innerHTML = `
            <p>Whitelisted: ${this.whitelisted}</p>
            <p>Blacklisted: ${this.blacklisted}</p>
        `;
    }

    increaseWhite() {
        this.whitelisted += 1
        this.render()
    }

    increaseBlack() {
        this.blacklisted += 1
        this.render()
    }
}

////////////////////////////////////////////////////////////////////////////////
////////////////////                  Initials          ////////////////////////
////////////////////////////////////////////////////////////////////////////////

// Show counter on top
const counter = new BlockCounter()

// Show spinner while operating
const spinner = new Spinner()

// Auto reloads app when idle for 15 minutes
// This is to simulatate to ensure latest data when user comes back to his phone after a while
autoReloadAfterIdle()


// Some other styles
GM_addStyle(`

    /* remove install app toast */
    div[data-comp-id~="22222"]:has( img[src*="MpdfZ1mwXmC.png"]){
      display: none !important;
    }

`)

////////////////////////////////////////////////////////////////////////////////
////////////////////                   Labels           ////////////////////////
////////////////////////////////////////////////////////////////////////////////

// this version of fb does not update navigator.lang on language change
// navigator.langs contain all of your preset languages. So we need to loop through it
const getLabels = obj => navigator.languages.map(lang => obj[lang]).flat()

if (devMode) console.log("navigator.languages", navigator.languages)
// Placeholder Message
const placeholderMsg = getLabels({
    'en-US': 'Removed',
    'en': 'Removed',
    'bn': 'বাতিল'
})[0]
// To be fixed later

// Suggested
const suggested = getLabels({
    'en-US': 'Suggested',
    'en': 'Suggested',
    'bn': 'আপনার জন্য প্রস্তাবিত'
})

// Sponsored
const sponsored = getLabels({
    'en-US': 'Sponsored',
    'en': 'Sponsored',
    'bn': 'স্পনসর্ড'
})
// Uncategorized
const unCategorized = getLabels({
    'en-US': ['Join', 'Follow'],
    'en': ['Join', 'Follow'],
    'bn': ['ফলো করুন', 'যোগ দিন']
})



//Whatever we wanna do with the convicts
findConvicts((convicts) => {

    console.table(convicts)
    for (const { element, reason, author } of convicts) {
        element.tabIndex = "-1"
        element.dataset.purged = "true"


        // Sponsored posts get removed in an "out of order" fashion automatically.
        // Having placeholder inside them results in a  scroll jump
        if (showPlaceholder && !(sponsored.includes(reason))) {
            element.dataset.actualHeight = "32"
            Object.assign(element.style, {
                height: "32px",
                overflowY: "hidden",
                pointerEvents: "none",
                position: "relative"
            })

            const overlay = document.createElement("div")
            Object.assign(overlay.style, {
                position: "absolute",
                inset: 0,
                background: "#242526",
                color: "#e4e6eb",
                display: "grid",
                pointerEvents: "auto",
                placeItems: "center",
                paddingInline: ".5rem"
            })
            overlay.innerHTML = `
                <p style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap; width: 100%; text-align: center;">
                    ${placeholderMsg}: ${author} (${reason})
                </p>
            `
            element.appendChild(overlay)

        } else {
            // Hide elements by resizing to 0px
            // Removing from DOM or display:none causes issues loading newer posts
            element.dataset.actualHeight = "0"
            Object.assign(element.style, {
                height: "0px",
                overflowY: "hidden",
                pointerEvents: "none"
            })

            //Hiding divider element preceding convicted element
            const { previousElementSibling: prevElm } = element
            if (prevElm.dataset.actualHeight !== "1") continue
            prevElm.style.marginTop = "0px"
            prevElm.style.height = "0px"
            prevElm.dataset.actualHeight = "0"
        }


        // Removing image links to restrict downloading unnecessary content
        for (const image of element.querySelectorAll("img")) {
            image.dataset.src = image.src
            //Clearing out src doesn't work as it gets populated again automatically
            image.removeAttribute("src")
            image.dataset.nulled = true
        }
    }

})


////////////////////////////////////////////////////////////////////////////////
////////////////////         function definitions       ////////////////////////
////////////////////////////////////////////////////////////////////////////////

function findConvicts(callback) {
    const observer = new MutationObserver((mutationList, observer) => {
        if (location.pathname !== '/') return
        if (devMode) console.time()
        spinner.show()
        const convicts = []

        for (const mutation of mutationList) {
            if (!(mutation.type === "childList" && mutation.target.matches("[data-type='vscroller']") && mutation.addedNodes.length !== 0)) continue
            // console.log(mutation)
            // console.table([...mutation.addedNodes].map(item => ({elm:item ,id: item.dataset.trackingDurationId, height: item.dataset.actualHeight})))
            for (const element of mutation.addedNodes) {
                // Check if element is an actual facebook post
                if (!(element.hasAttribute("data-tracking-duration-id"))) continue

                let suspect = false
                let reason
                let raw
                let author

                for (const span of element.querySelectorAll("span.f5")) {
                    if (![...suggested, ...sponsored].some(str => span.textContent.includes(str))) continue
                    suspect = true
                    reason = span.innerHTML.split("󰞋")[0]
                    raw = span.innerHTML
                    break
                }

                if (!suspect) {
                    const span = element.querySelector("span.f2:not(.a)")

                    if (span && unCategorized.some(str => span.textContent === str)) {
                        suspect = true
                        reason = span.textContent
                        raw = span.textContent
                    }
                }

                if (suspect) {
                    author = element.querySelector("span.f2").innerHTML
                    if (author.includes("Sponsored")) console.log(element)
                }

                if (suspect) {
                    convicts.push({
                        element,
                        reason,
                        raw,
                        id: element.dataset.trackingDurationId,
                        author
                    })
                    counter.increaseBlack()
                } else {
                    counter.increaseWhite()
                }

            }
        }

        if (!!convicts.length) callback(convicts)

        if (devMode) console.timeEnd()
        spinner.hide()
        // Set new calculated height to the bottom ".filler" element
        // We need to calculate it after all the convicts are taken care of
        // *** It seems we dont need it anymore. Completely hiding "Sponsored" posts fixed it for us
        // setFillerHeight(mutationList)
    })

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

// setFillerHeight is omitted
// function setFillerHeight(mutationList) {
//     const fillerNode = document.querySelectorAll('.filler')[1]
//     if (!fillerNode) return
//     let newHeight = 0
//     for (const mutation of mutationList) {
//         if (!(mutation.type === "childList" && mutation.target.matches("[data-type='vscroller']") && mutation.addedNodes.length !== 0)) continue

//         newHeight += [...mutation.addedNodes].reduce((accumulator, element) => (
//             accumulator += element.classList.contains('displayed') || element.classList.contains('filler') ? 0 : element.clientHeight
//         ), 0)
//     }
//     fillerNode.style.height = newHeight
// }


function autoReloadAfterIdle(minutes = 15) {
    let leaveTime

    document.addEventListener('visibilitychange', () => {
        if (document.hidden) {
            leaveTime = new Date()
        } else {
            let currentTime = new Date()
            let timeDiff = (currentTime - leaveTime) / 60000
            if (timeDiff > minutes) location.reload()
        }
    })
}