Improved Channel Select Menu for Kbin

Adds subscribed magazines and liked collections to the channel select menu.

// ==UserScript==
// @name         Improved Channel Select Menu for Kbin
// @namespace    http://tampermonkey.net/
// @version      0.3.0
// @description  Adds subscribed magazines and liked collections to the channel select menu.
// @author       NeighborlyFedora
// @match        *://kbin.social/*
// @match        *://kbin.earth/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=kbin.social
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js
// @license      GPL-3.0-or-later
// ==/UserScript==

//Code partially based on Floating Subs List by raltsm4k (https://greasyfork.org/en/scripts/469121-floating-subs-list)



let user;
let cacheId;
let subs = [];
let clls = [];
let subsHtml = [];
let cllsHtml = [];
let settings = {
    cllsFirst: true,
    cacheEnabled: true,
    defaultIcons: true,
    fillOnChange: true
};
const SETTINGS_TEXT = {
    cllsFirst: "List liked collections before subscribed magazines.",
    cacheEnabled: "Cache menu items for faster loading.",
    defaultIcons: "Add placeholder icons to collections and iconless magazines.",
    fillOnChange: "Immediately reload menu when a magazine is subscribed to."
}
let settingsOpen = false;
let isFilling = false;
let fetchTries = 3;



main();
const observer = new MutationObserver( function(records){
    for(const record of records){
        if( Array.from(record.addedNodes).filter(node => node.nodeName == "BODY").length ){
            main();
        }
        if(settings["fillOnChange"]){
            const subButtons = Array.from(record.addedNodes).filter( node => node.classList !== undefined && node.classList.contains("magazine__subscribe") );
            if(subButtons.length){
                fill();
            };
        }
    }
});
observer.observe(document,{subtree: true, childList: true});



function main() {
    "use strict";

    user = document.querySelector("#header a.login").getAttribute("href");
    cacheId = "icsm_" + user;

    if(user === "/login") return;

    const channelList = document.querySelector("#header li:has(a[title='Select a channel']) .dropdown__menu");
    Object.assign(channelList, {
        id: "channel-list"
    });

    let clRefresh = document.querySelector("#cl-refresh")
    if(clRefresh === null){
        clRefresh = Object.assign(document.createElement("button"), {
            id: "cl-refresh",
            title: "Refresh list",
        });
        clRefresh.appendChild(Object.assign(document.createElement("i"), {
            className: "fa-solid fa-rotate"
        }));
    }
    
    channelList.prepend(clRefresh);
    clRefresh.addEventListener("click", fill);

    let clSettings = document.querySelector("#cl-settings")
    if(clSettings === null){
        clSettings = Object.assign(document.createElement("button"), {
            id: "cl-settings",
            title: "Settings",
        });
        clSettings.appendChild(Object.assign(document.createElement("i"), {
            className: "fa-solid fa-gear"
        }));
    }
    channelList.prepend(clSettings);

    clSettings.addEventListener("click", function(){
        if(settingsOpen){
            closeSettings();
        }else{
            openSettings();
        }
        settingsOpen = !settingsOpen;
    });

    $(document).on("keydown", function(event) {
        if(event.key == "Escape"){
            closeSettings();
        }
    });

    const icsmSettings = Object.assign(document.createElement("ul"), {
        id: "icsm-settings"
    });
    $(icsmSettings).hide();
    channelList.append(icsmSettings);

    icsmSettings.append(Object.assign(document.createElement("h3"), {
        textContent: "Settings"
    }));

    $("<style>").text(
        `
        #header #channel-list {
            scroll-behavior: auto;
            max-width: 60vw;
            width: 25rem;
            height: 25rem;
            overflow-x: hidden;
            overflow-y: scroll;
        }

        #header #channel-list h3 {
            border-bottom: var(--kbin-sidebar-header-border);
            color: var(--kbin-sidebar-header-text-color);
            font-size: .8rem;
            width: 95%;
            margin: 1rem 0 0 2.5%;
            text-transform: uppercase;
        }

        #cl-refresh {
            display: inline !important;
            position: absolute;
            background: none;
            border: 0;
            color: var(--kbin-meta-link-color);
            cursor: pointer;
            height: auto;
            width: auto;
            text-indent: 0;
            right: 2.5%;
            text-align: right;
            margin: 1rem 0 0 1rem;
            padding: 0 !important;
        }

        #cl-settings {
            display: inline !important;
            position: absolute;
            background: none;
            border: 0;
            color: var(--kbin-meta-link-color);
            cursor: pointer;
            height: auto;
            width: auto;
            text-indent: 0;
            right: calc(2.5% + 1.25rem);
            text-align: right;
            margin: 1rem 0 0 1rem;
            padding: 0 !important;
        }

        #cl-refresh:hover, #cl-settings:hover {
            color: var(--kbin-meta-link-hover-color);
        }

        #icsm-settings {
            padding: 0;
        }

        #icsm-settings li {
            padding: .35rem 1rem;
        }

        #icsm-settings input {
            min-width: 1.5rem;
            margin-right: .4rem;
        }

        #header #channel-list li {
            height: auto;
        }

        #header #channel-list #sub_item a:has(figure) {
            display: flex !important;
            justify-content: left;
        }

        #header #channel-list a {
            overflow-x: hidden;
            position: relative;
            padding: .35rem 1rem !important;
        }

        #header #channel-list #sub_item figure{
            float: left;
            margin-right: .25rem;
        }

        #header #channel-list #sub_item figure :is(img,i){
            width: 1.25rem;
            height: 1.25rem;
            vertical-align: middle;
        }

        #header #channel-list #sub_item figure i {
            display: flex;
            align-items: center;
            justify-content: center;
            position: relative;
            top: .15rem;
        }

        #header #channel-list #sub_item figure i::before{
            vertical-align: middle;

        }

        #header #channel-list::-webkit-scrollbar {
            width: 8px;
        }

        .rounded-edges #header #channel-list::-webkit-scrollbar-track {
            border-top-right-radius: var(--kbin-rounded-edges-radius);
            border-bottom-right-radius: var(--kbin-rounded-edges-radius);
        }

        .rounded-edges #header #channel-list::-webkit-scrollbar-thumb  {
            border-radius: var(--kbin-rounded-edges-radius);
        }


        #header #channel-list::-webkit-scrollbar-track {
            background: var(--kbin-bg);
            border-left: var(--kbin-section-border);
        }


        #header #channel-list::-webkit-scrollbar-thumb {
            background: var(--kbin-meta-link-color);
            border-left: var(--kbin-section-border);
            transition-duration: 0.4s;
        }
        #header #channel-list::-webkit-scrollbar-thumb:hover {
            background: var(--kbin-meta-link-hover-color);
        }
        `
    ).appendTo(document.head);

    const settingsCache = JSON.parse(localStorage.getItem(cacheId + "_settings"));
    if(settingsCache !== null){
        Object.assign(settings, settingsCache);
    }
    for (const [key, val] of Object.entries(settings)) {
        if(SETTINGS_TEXT[key] === undefined) continue;
        const item = document.createElement("li");
        const checkbox = Object.assign(document.createElement("input"), {
            type: "checkbox",
            checked: val
        });
        item.append(checkbox);
        item.append(Object.assign(document.createElement("span"), {
            textContent: SETTINGS_TEXT[key]
        }));
        icsmSettings.append(item);
        checkbox.addEventListener("change", function(event){
            settings[key] = checkbox.checked;
            localStorage.setItem(cacheId + "_settings", JSON.stringify(settings));
        });
    }
    localStorage.setItem(cacheId + "_settings", JSON.stringify(settings));

    
    const cache = JSON.parse(localStorage.getItem(cacheId));
    if (!settings["cacheEnabled"] || cache === null || Date.now() >= cache.expire) {
        fill();
    } else if(!isFilling) {
        isFilling = true;
        empty();
        console.log("Fetching from cache....");
        cache.subs.forEach(function(cached_html){
           subs.push($(Object.assign(document.createElement("li"),{
               id: "sub_item"
           })).html(cached_html))
        });

        cache.clls.forEach(function(cached_html){
           clls.push($(Object.assign(document.createElement("li"),{
               id: "sub_item"
           })).html(cached_html))
        });
        complete();
    }

}

function fill(){
    if(isFilling) return;
    isFilling = true;
    $("#channel-list").children("li").show();
    $("#channel-list").children("h3").show();
    $("#icsm-settings").hide();
    localStorage.removeItem(cacheId);
    empty();
    $("#cl-refresh").find("i").addClass("fa-spin");
    fetchSubs(1);
}

function empty(){
    subs.forEach((sub) => { sub.remove();} );
    clls.forEach((cll) => { cll.remove();} );
    subs = [];
    subsHtml = [];
    clls = [];
    cllsHtml = [];
}

function fetchSubs(page){
    $.get( window.location.origin + "/settings/subscriptions/magazines?p=" + page, function(data) {
        const $dom = $($.parseHTML(data));
        const $subsList = $dom.find("#content .magazines ul");
        console.log("Fetching page " + page + " of subscriptions....");
        if (!$subsList.length) {
            if (fetchTries > 0) {
                console.log("Failed to fetch page " + page + " of subscriptions. Retrying....");
                fetchTries--;
                fetchSubs(page);
            } else {
                console.log("Failed to fetch page " + page + " of subscriptions. Out of attempts.");
                failSubs();
            }
        }else{
            fetchTries = 3;
            const $newSubs = $subsList.children("li");
            $newSubs.each(function() {

                $(this).prop("id", "sub_item");

                const $link = $(this).find("a");

                const $icon = $(this).find("figure");
                if($icon.length){
                    $link.prepend($icon);
                }

                $link.removeClass();
                if(window.location.href.includes("/m/"+$link.text())){
                   $link.addClass("active");
                }

                
                $(this).append($link);
                $(this).find("small").remove();
                $(this).find("div").remove();
                subs.push($(this));
                subsHtml.push($(this).html());

            });
            const $pg = $dom.find("#content .pagination__item--next-page.pagination__item--disabled");

            if ($pg.length) {
                fetchClls(1);
            } else {
                fetchSubs(page+1);
            }
        }
    }).fail(function() {
        console.log("Error occurred in reaching page " + page + " of subscriptions.");
        failSubs();
    });
}

function failSubs(){
    console.log("Skipping to collections.");
    fetchClls(1);
}

function fetchClls(page){
    $.get( window.location.origin + "/magazines/collections?p=" + page, function(data) {
        const $dom = $($.parseHTML(data));
        const $cllsList = $dom.find("#content .categories tbody");
        console.log("Fetching page " + page + " of collections....");
        if (!$cllsList.length) {
            if (fetchTries > 0) {
                console.log("Failed to fetch page " + page + " of collections. Retrying...");
                fetchTries--;
                fetchClls(page);
            } else {
                console.log("Failed to fetch page " + page + " of collections. Out of attempts.");
                failClls()
            }
        }else{
            fetchTries = 3;
            const $newClls = $cllsList.children("tr:has(button.active)");
            $newClls.each(function() {

                const $link = $(this).find("td:first-child a");

                $link.removeClass();
                const $li = $(document.createElement("li"));
                $li.prop("id", "sub_item");
                $li.append($link);
                clls.push($li);
                cllsHtml.push($li.html());

            });
            const $pg = $dom.find("#content .pagination__item--next-page.pagination__item--disabled");
            if ($pg.length) {
                if(settings["cacheEnabled"]){
                    cache();
                }
                complete();
            } else {
                fetchClls(page+1);
            }
        }
    }).fail(function() {
        console.log("Error occurred in reaching page " + page + " of collections.");
        failClls();
    });
}

function failClls(){
    console.log("Skipping to menu completion.");
    cache();
    complete();
}

function cache(){
    localStorage.setItem(cacheId, JSON.stringify({subs: subsHtml, clls: cllsHtml, expire: Date.now() + 15 * 60 * 1000}));
}


function complete(){

    console.log("Completing menu....");

    $("#cl-refresh").find("i").removeClass("fa-spin");

    $("#channel-list").children("h3").remove();

    $("#channel-list").prepend($(Object.assign(document.createElement("h3"), {
        id: "cl-header-feeds",
        textContent: "Feeds"
    })));

    $("#channel-list").prepend($("#cl-refresh"));
    $("#channel-list").prepend($("#cl-settings"));

    if(settings["cllsFirst"]){
        completeClls();
        completeSubs();
    }else{
        completeSubs();
        completeClls();
    }

    $("#channel-list").append($("#icsm-settings"));

    console.log("Done!");

    isFilling = false;
}


function completeSubs(){

    $("#channel-list").append($(Object.assign(document.createElement("h3"), {
        id: "cl-header-subs",
        textContent: "Subscribed Magazines"
    })));

    subs.sort(function(a, b){
        return a.find("a").text().trim().toLowerCase().localeCompare(b.find("a").text().trim().toLowerCase());
    });

    subs.forEach(function($sub) {
        $("#channel-list").append($sub);
        const $link = $sub.find("a")
        if(!$sub.find("figure img").length) {
            $sub.find("figure").remove();
            if(settings["defaultIcons"]){
                const icon = document.createElement("figure");
                icon.append(Object.assign(document.createElement("i"), {
                    className: "fa-solid fa-newspaper"
                }));
                $link.prepend($(icon));
            }
        }
        if( window.location.href.endsWith("/m/"+$link.text().trim()) || window.location.href.includes("/m/"+$link.text().trim()+"/") ){
            $link.addClass("active");
        }else{
            $link.removeClass("active");
        }
    })

}


function completeClls(){

    $("#channel-list").append($(Object.assign(document.createElement("h3"), {
        id: "cl-header-clls",
        textContent: "Liked Collections"
    })));

    clls.sort(function(a, b){
        return a.text().trim().toLowerCase().localeCompare(b.text().trim().toLowerCase());
    });

    clls.forEach(function($cll) {
        $("#channel-list").append($cll);
        const $link = $cll.find("a");
        $cll.find("figure").remove();
        if(settings["defaultIcons"]){
            const icon = document.createElement("figure");
            icon.append(Object.assign(document.createElement("i"), {
                className: "fa-solid fa-folder-open"
            }));
            $link.prepend($(icon));
        }
        if( window.location.href.includes("/c/"+$link.text().trim()) || window.location.href.includes("/c/"+$link.text().trim()+"/") ){
            $link.addClass("active");
        }else{
            $link.removeClass("active");
        }
    });
}


function openSettings(){
    if(isFilling) return;
    $("#channel-list").children("li").hide();
    $("#channel-list").children("h3").hide();
    $("#icsm-settings").show();
}


function closeSettings(){
    complete();
    $("#channel-list").children("li").show();
    $("#channel-list").children("h3").show();
    $("#icsm-settings").hide();
}