Mastodon Timeline Counter

Indicates the number of remaining posts on the timeline.

// ==UserScript==
// @name         Mastodon Timeline Counter
// @version      1.2.1
// @description  Indicates the number of remaining posts on the timeline.
// @namespace    http://tampermonkey.net/
// @author       Bene Laszlo
// @match        https://mastodon.social/@*
// @match        https://mastodon.online/@*
// @match        https://mas.to/@*
// @icon         https://mastodon.social/packs/media/icons/favicon-16x16-c58fdef40ced38d582d5b8eed9d15c5a.png
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    var list, listContainerEl, // list of all post containers; element containing the posts
        boxEl, counterEl, textEl, // output box element; output counter element; output additional text element

        // number of post container packs already displayed.
        // 20* post containers get created every time scrolling reaches bottom. (* less than 20 at the end of the timeline)
        // so a PCP has 20 post containers that get filled or unfilled with posts according to scroll position
        // (i.e. count = actual count rounded up to 20)
        count,

        lastCount=0, // buffered count from previous iteration
        subCount, // number of already seen posts inside a post container pack
        placeholdersJustCreated, // the moment a new pack was created
        output, // the counter on the screen showing posts left
        total, // estimated total number of posts collected from the timeline header
        pack, packSize, // the last 20 elements of "list" (or less than 20 at the end of the timeline); size of the pack (in the manner just mentioned)
        scrollCount=0, // counter for only every 12th scroll to take effect
        lastPos, // buffered scroll position from previous iteration
        firstContentFound, // the place inside the pack where the re-hidden posts
        first = true, // if chit turns false, the addition text changes from "total" to "more to go"
        theme; // site theme (used for setting color of additional text)

    preInit();

    // wait for timeline to load before anything can happen
    function preInit() {
        var t = setInterval(function() {
            var coll = document.getElementsByClassName('item-list');
            if (coll.length) {
                listContainerEl = coll[0];
                clearTimeout(t);
                init();
            }
        }, 200);
    }

    function init() {
        // set the colors according to the theme
        for (const name of ['default','contrast','mastodon-light']) {
            if (document.body.classList.contains('theme-'+name)) {
                theme = name;
                break;
            }
        }

        // info from timeline header as source for total number of posts
        var totalEl = document.getElementsByClassName('account__header__extra__links')[0].firstElementChild;
        output = total = totalEl.getAttribute('title').replace(',', '');

        // THE COUNTER ELEMENT
        var navPanel = document.getElementsByClassName('navigation-panel')[0];
        var boxX = navPanel.getBoundingClientRect().left;
        // counter container
        boxEl = document.createElement("div");
        boxEl.style.position = 'fixed';
        boxEl.style.left = (boxX+12)+'px';
        boxEl.style.bottom = '10px';
        boxEl.style.display = 'flex';
        boxEl.style.gap = '4px';
        boxEl.style.alignItems = 'flex-end';
        // counter
        counterEl = document.createElement("div");
        counterEl.style.fontSize = '40px';
        counterEl.style.lineHeight = '.9em';
        counterEl.style.fontWeight = '500';
        // additional text
        textEl = document.createElement("div");
        textEl.innerText = 'total';
        var textColor;
        switch (theme) {
            case 'default': textColor='#606984'; break;
            case 'contrast': textColor='#c2cede'; break;
            case 'mastodon-light': textColor='#444b5d'; break;
        }
        textEl.style.color = textColor;
        boxEl.append(counterEl);
        boxEl.append(textEl);

        // featured hashtags should move aside
        for (var el of document.getElementsByClassName('getting-started__trends')) {
            el.style.position = 'relative';
            el.style.top = '-70px';
            el.style.borderBottom = '1px solid #393f4f';
        }

        putOutput(total);

        document.body.appendChild(boxEl);

        document.addEventListener('scroll', handleScroll);
    }

    // SCROLL EVENT, THE MAIN FUNCTION
    function handleScroll() {
        if (window.scrollY <= lastPos) return; // scolling up or not scrolling further down

        // update the counter (at every 12 scrolls)
        if (scrollCount == 0)
        {
            // if new pack of post placeholders was just created by the site (1 pack = 20 placeholders)
            placeholdersJustCreated = false;
            // the full collection of post placeholders
            list = listContainerEl.getElementsByTagName('article');
            // The number of visible post placeholders.
            // This is not equal to the actual count, because 20 placeholders are created at a time.
            count = list.length;

            // new post placeholders have just been created
            if (count != lastCount)
            {
                placeholdersJustCreated = true;

                // get the newly created post placeholders
                packSize = count-lastCount; // size of the new post placeholder pack
                pack = Array.prototype.slice.call(list, -packSize); // the last part of that size of the full post placeholder collection

                // buffer the previous count
                lastCount = count;
            }

            // SUBCOUNT (COUNT PER PACK [= 20 post placeholders])
            // figuring out how many of the new post placeholder pack was already seen
            //
            // set-up of a pack:
            // - already re-emptied post placeholders (if any), because they're scrolled past
            // - loaded post(s)
            // - still empty post placeholders, because they're not yet scrolled to
            if (!placeholdersJustCreated)
            {
                firstContentFound = false; // the pointer where the re-emptied placeholders are over
                for (var i in pack)
                {
                    const article = pack[i];
                    const isEmpty = article.style.overflow && article.style.overflow=='hidden';

                    // posts scrolled past, which are ALREADY re-unloaded
                    if (!firstContentFound && isEmpty) continue;

                    // first post after the empty placeholders
                    firstContentFound = true;

                    // first post that is STILL unloaded
                    if (isEmpty) break;
                }

                // the number of seen posts inside the pack is the loop index
                subCount = firstContentFound ? i : 0; // (I don't remember whether there is actually a "0" state, but let's just leave it here)
            }
            else {
                subCount = 0;
            }

            // THE ACTUAL COUNT
            // - count: total number of post placeholders (i.e. seen posts rounded up to 20)
            // - packSize: usually 20; 20 or less if it's the end of the timeline
            // - subCount: the number of posts scrolled past inside the pack
            // - packSize-subCount: excludes the placeholders that are not yet scrolled to from the pack
            var realCount = count-(packSize-subCount);

            // The last pack is recognized by having less than 20 posts. (This has a 95% chance of working.)
            // The last post of this pack makes the counter come to a close, and zeroes itself down with an animation.
            if (packSize<20 && realCount==count-1) {end(output); return;}

            // THE OUTPUT: POSTS LEFT
            output = total-realCount;
            if (output!=total) output++; // a post is digested when the NEXT post is loaded and its top is already visible

            // on first post scroll, alter the additional text
            if (first && subCount>1) {
                textEl.innerText = 'more to go';
                first = false;
            }
        }

        // every 12th scroll takes effect (when scrollCount is 0)
        scrollCount ++;
        if (scrollCount==12) scrollCount = 0;
        lastPos = window.scrollY; // buffering the scroll position for next iteration

        // putting output on screen
        putOutput(output);
    }

    // PUTTING OUTPUT ON SCREEN
    function putOutput(n) {
        // displaying output while fixing bad kerning of number 1 (really fucked with my OCD)
        var outputStr = '';
        const digits = Array.from(n.toString());
        for (const [j, d] of digits.entries()) {
            // modified digit is 1 if it's not the last digit
            outputStr += (d!=1 || j==digits.length-1) ? d : '<span>'+d+'</span>';
        }
        counterEl.innerHTML = outputStr;
        for (var el of counterEl.getElementsByTagName('span')) { // inline styling of <span> doesn't take effect FSR
            el.style.position = 'relative';
            el.style.left = '1px';
        }
    }

    // zeroing the counter when the timeline has ended, with a fancy animation
    // necessary because this userscript relies on the timeline header about the number of posts, which is never exact
    function end(n) {
        document.removeEventListener('scroll', handleScroll);
        var t = setInterval(function() {
            n--;
            putOutput(n);
            if (n<=0) clearTimeout(t);
        }, 60);
    }
})();