DTF Enhancer

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

当前为 2024-05-29 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         DTF Enhancer
// @namespace    http://tampermonkey.net/
// @version      0.1.7
// @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 = '1280px'; // Ширина контентной области. Можно задать любое значение, например: 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 || `/u/${item.id}-${item.name.toLowerCase()}`;

      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('/u/')[1].split('-')[0];

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