Mastodon Timeline Counter

Indicates the number of remaining posts on the timeline.

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         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);
    }
})();