DTF Enhancer

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

// ==UserScript==
// @name         DTF Enhancer
// @namespace    http://tampermonkey.net/
// @version      0.1.8
// @description  Выводит список подписок в сайдбаре и раскрывает список комментов
// @author       You
// @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
// @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 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 styles = `
      :root {
        ${USE_WIDE_LAYOUT ? '--layout-max-width: none;' : '--layout-max-width: ' + CONTENT_WIDTH}
      }

      .sidebar-subs {
        display: flex;
        flex-direction: column;
        overflow: auto;
        margin: 24px 0;
      }

      .sidebar-item._sub img.icon {
        width: 24px;
        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;
      }
    `;

    document.head.insertAdjacentHTML("beforeend", `<style type="text/css" id="dtfSubsStyles">${styles}</style>`)
  };

  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 sidebarItems = items.map((item) => {
      const href = item.uri || `/id${item.id}`;

      return createSidebarItem(item.name, item.avatar.data.uuid, href);
    });

    const title = cn('div', { class: 'sidebar__title' }, ['Подписки:']);

    const listWrapper = cn('div', { class: 'sidebar__subs' }, sidebarItems);

    return cn('div', { class: 'sidebar-subs' }, [title, listWrapper]);
  };

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