DTF Enhancer

Выводит список подписок в сайдбаре и раскрывает список комментов

// ==UserScript==
// @name         DTF Enhancer
// @namespace    http://tampermonkey.net/
// @version      0.2.3
// @description  Выводит список подписок в сайдбаре и раскрывает список комментов
// @author       undfndusr
// @match        *://dtf.ru/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=dtf.ru
// @run-at       document-end
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.addStyle
// @license      MIT
// ==/UserScript==

(function() {
    const USE_WIDE_LAYOUT = 0; // Показывать страницу на всю ширину. 0 - выкл, 1 - вкл
    const CONTENT_WIDTH = '1400px'; // Ширина контентной области. Можно задать любое значение, например: 960px или 1200px
    const SHOW_SCROLL_UP_BUTTON = 1; // Показывать кнопку "вверх". 0 - выкл, 1 - вкл
    const ENABLE_AUTO_EXPAND_COMMENTS = 1; // Автоматически раскрывать общий список комментариев. 0 - выкл, 1 - вкл
    const ENABLE_STYLE_FIXES = 1;

    const dict = {"(": "", ")": "", " ":"-","Ё":"YO","Й":"I","Ц":"TS","У":"U","К":"K","Е":"E","Н":"N","Г":"G","Ш":"SH","Щ":"SCH","З":"Z","Х":"H","Ъ":"'","ё":"yo","й":"i","ц":"ts","у":"u","к":"k","е":"e","н":"n","г":"g","ш":"sh","щ":"sch","з":"z","х":"h","ъ":"'","Ф":"F","Ы":"I","В":"V","А":"A","П":"P","Р":"R","О":"O","Л":"L","Д":"D","Ж":"ZH","Э":"E","ф":"f","ы":"i","в":"v","а":"a","п":"p","р":"r","о":"o","л":"l","д":"d","ж":"zh","э":"e","Я":"Ya","Ч":"CH","С":"S","М":"M","И":"I","Т":"T","Ь":"'","Б":"B","Ю":"YU","я":"ya","ч":"ch","с":"s","м":"m","и":"i","т":"t","ь":"'","б":"b","ю":"yu"};

    const transliterate = (word) => word.split('').map((char) => typeof dict[char] === 'undefined' ? char : dict[char]).join("");

    const cn = (tagName, attrs = {}, childrenList = [], parentNode = null) => {
        const node = document.createElement(tagName);

        if (typeof attrs === 'object') {
            for (const attrsKey in attrs) node.setAttribute(attrsKey, attrs[attrsKey]);
        }

        if (Array.isArray(childrenList)) {
            childrenList.forEach(child => {
                node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
            });
        }

        if (parentNode) {
            parentNode.appendChild(node);
        }

        return node;
    };

    const getDomElementAsync = (selector, timerLimit = 10000, debugMessage = '') => {
        return new Promise((resolve, reject) => {
            try {
                let timerId;

                setTimeout(() => {
                    if (timerId) {
                        console.debug(`Время ожидания DOM элемента ${selector} истекло (${timerLimit / 1000}s)`);
                        resolve(null);
                        clearTimeout(timerId);
                    }
                }, timerLimit);

                const tick = () => {
                    const element = document.querySelector(selector);

                    if (element) {
                        clearTimeout(timerId);
                        resolve(element);
                    } else {
                        timerId = setTimeout(tick, 100);
                    }
                };

                tick();
            } catch (e) {
                reject(e);
            }
        });
    };

    const debounce = (func, wait) => {
        let timeout;
        return function (...args) {
            return new Promise(resolve => {
                clearTimeout(timeout);
                timeout = setTimeout(() => {
                    timeout = null;
                    Promise.resolve(func.apply(this, [...args])).then(resolve);
                }, wait);
            });
        };
    };

    const observeUrlChange = async (onChange) => {
        await GM.setValue('currentUrl', window.location.href);

        const onChangeHandler = async () => {
            const oldHref = await GM.getValue('currentUrl');
            const newHref = window.location.href;

            if (oldHref !== newHref) {
                console.log('observeUrlChange');

                await GM.setValue('currentUrl', newHref);
                onChange?.();
            }
        };

        const debouncedOnChangeHandler = debounce(onChangeHandler, 500);

        const observer = new MutationObserver(debouncedOnChangeHandler);

        observer.observe(document.body, {
            childList: true,
            subtree: true,
        });
    };

    const injectStyles = () => {
        const mainStyles = `
          :root {
            ${USE_WIDE_LAYOUT ? '--layout-max-width: none;' : '--layout-max-width: ' + CONTENT_WIDTH}
          }

          .sidebar__section._subs {
            margin: 24px 0;
            overflow: hidden;
          }

          .sidebar-item._sub img.icon {
            width: 28px;
            border-radius: 50%;
          }

          .sidebar-item._sub span {
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            min-width: 1px;
          }

          /* перебиваем стили DTF */
          .layout,
          .header__layout {
            display: flex;
            gap: 24px;

            ${SHOW_SCROLL_UP_BUTTON ? 'padding: 0 40px;' : ''}
          }

          .header__left,
          .aside--left {
            width: 220px;
            flex-shrink: 0;
          }

          .header__right,
          .aside--right {
            width: 300px;
            flex-shrink: 0;
          }

          .header__right {
            justify-content: flex-end;
            margin-left: 0;
          }

          .sidebar__main {
            display: flex;
            flex-direction: column;
            flex-shrink: 0;
            min-width: 1px;
            overflow: auto;
          }

          .sidebar-item {
            flex-shrink: 0;
          }

          .sidebar-editor-button {
            margin-top: 24px;
          }

          .sidebar-editor-buttons {
            margin-top: auto;
            margin-bottom: 16px;
          }

          .account-menu {
            visibility: hidden;
          }

          body.dtf-subs-script-inited .account-menu {
            visibility: visible;
          }

          /* кнопка "вверх" */
          .scroll-up-button {
            display: flex;
            justify-content: center;
            position: fixed;
            top: var(--layout-header-height);;
            left: 0;
            width: 40px;
            height: 100vh;
            padding-top: 10px;
            background-color: transparent;
            cursor: pointer;
            z-index: var(--layout-z-index-header);
            opacity: 0;
            pointer-events: none;
            transition: background-color 200ms ease-out, opacity 200ms ease-out;
          }

          [data-theme="light"] .scroll-up-button:hover {
            background-color: var(--theme-color-brand-header);
          }

          [data-theme="dark"] .scroll-up-button:hover {
            background-color: rgba(255, 255, 255, .1);
          }

          .scroll-up-button use {
            display: none;
          }

          .scroll-up-button.up use:nth-child(1) {
            display: block;
          }

          .scroll-up-button.down use:nth-child(2) {
            display: block;
          }

          .scroll-up-button.visible {
            opacity: 1;
            pointer-events: all;
          }
        `;

        const styleFixes = `
            #app,
            .feed-page {
                display: flex;
                flex-direction: column;
            }
            .supbar,
            .rotator,
            .widgets > div:not(.widget-item),
            .widgets + div {
                order: 1;
                pointer-events: none;
                background: var(--theme-color-background);
            }

            .widgets > div:not(.widget-item) > *,
            .widgets + div > * {
                opacity: 0;
            }
        `;

        GM.addStyle(mainStyles);

        if (ENABLE_STYLE_FIXES) {
            GM.addStyle(styleFixes);
        }
    };

    const fetchSubs = async (userId) => {
        const resp = await fetch(`https://api.dtf.ru/v2.5/subsite/subscriptions?subsiteId=${userId}`);
        const { result } = await resp.json();

        return result.items;
    }

    const getImageUrl = (uuid) => `https://leonardo.osnova.io/${uuid}/-/scale_crop/32x32/`;

    const createSidebarItem = (name, imageId, href) => {
        const imgEl = cn('img', { class: 'icon', src: getImageUrl(imageId) });
        const nameEl = cn('span', {}, [name]);
        const result = cn('a', { class: 'sidebar-item _sub', href: transliterate(href), alt: name }, [imgEl, nameEl]);

        return result;
    };

    const createSidebarList = (items) => {
        const title = cn('div', { class: 'sidebar__title' }, ['Подписки']);
        const sidebarItems = items.map((item) => createSidebarItem(item.name, item.avatar.data.uuid, item.uri || `/id${item.id}`));

        return cn('div', { class: 'sidebar__section _subs' }, [title, ...sidebarItems]);
    };

    const getProfileUrl = async () => {
        const userButton = await getDomElementAsync('.account-button__inner');
        userButton.click();

        const profileMenuItem = await getDomElementAsync('.user-card');
        userButton.click();

        return profileMenuItem.href;
    }

    const getUserId = async () => {
        const profileUrl = await getProfileUrl();
        const userId = profileUrl.split('/id')[1];

        return userId || null;
    };

    const injectSubscriptions = async () => {
        const userId = await getUserId();

        document.body.classList.add('dtf-subs-script-inited');

        if (!userId) {
            return;
        }

        const subs = await fetchSubs(userId);
        const list = createSidebarList(subs);

        const firstSidebarSection = await getDomElementAsync('.sidebar__section');
        firstSidebarSection.after(list);
    };

    const runAutoExpandComments = () => {
        const expandComments = async () => {
            const isCommentsSlicePage = location.search.includes('comment=');

            if (isCommentsSlicePage) {
                // На странице со срезом комментариев не запускаем автораскрытие общего списка.
                // Кнопка expandCommentsButton вместо раскрытия открывает страницу самого поста.
                // Из-за чего страница среза не отображается, а сразу происходит редирект.
                return;
            }

            const expandCommentsButton = await getDomElementAsync('.comments-limit__expand');
            expandCommentsButton?.click();
        };

        observeUrlChange(expandComments);

        expandComments();
    };

    const injectScrollUp = () => {
        const createScrollUpButton = () => {
            const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
            svg.setAttribute('class','icon');
            svg.setAttribute('width','20px');
            svg.setAttribute('height','20px');

            const iconUp = document.createElementNS('http://www.w3.org/2000/svg', 'use');
            iconUp.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href','#chevron_up');

            const iconDown = document.createElementNS('http://www.w3.org/2000/svg', 'use');
            iconDown.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href','#chevron_down');

            svg.appendChild(iconUp);
            svg.appendChild(iconDown);

            return cn('div', { class: 'scroll-up-button' }, [svg], document.querySelector('body'));
        };

        const scrollUpBtn = createScrollUpButton();

        const setUpIcon = () => {
            scrollUpBtn.classList.remove('down');
            scrollUpBtn.classList.add('up');
        };

        const setDownIcon = () => {
            scrollUpBtn.classList.remove('up');
            scrollUpBtn.classList.add('down')
        };

        let prevScrollPosition = 0;

        window.addEventListener('scroll', debounce(() => {
            if (window.scrollY) {
                scrollUpBtn.classList.add('visible');

                setUpIcon();
            } else {
                setDownIcon();
            }
        }, 100));

        scrollUpBtn.addEventListener('click', () => {
            if (window.scrollY) {
                prevScrollPosition = window.scrollY;

                setDownIcon();

                window.scrollTo({
                    top: 0,
                    left: 0,
                    behavior: "smooth",
                });
            } else {
                setUpIcon();

                window.scrollTo({
                    top: prevScrollPosition,
                    left: 0,
                    behavior: "smooth",
                });
            }
        });
    };

    const start = async () => {
        console.debug('DTF Enhancer started');

        injectStyles();
        injectSubscriptions();

        if (ENABLE_AUTO_EXPAND_COMMENTS) {
            runAutoExpandComments();
        }

        if (SHOW_SCROLL_UP_BUTTON) {
            injectScrollUp();
        }
    };

    const init = async () => {
        if (document.visibilityState === 'visible') {
            start();
        } else {
            // Для вкладок открытых в фоне запускаем скрипт после перехода на вкладку
            addEventListener("visibilitychange", (event) => {
                if (document.visibilityState === 'visible') {
                    start();
                }
            }, { once: true });
        }
    };

    init();
})();