DTF Enhancer

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

  1. // ==UserScript==
  2. // @name DTF Enhancer
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.1.9
  5. // @description Выводит список подписок в сайдбаре и раскрывает список комментов
  6. // @author undfndusr
  7. // @match *://dtf.ru/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=dtf.ru
  9. // @run-at document-end
  10. // @grant GM.getValue
  11. // @grant GM.setValue
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. const USE_WIDE_LAYOUT = 0; // Показывать страницу на всю ширину. 0 - выкл, 1 - вкл
  17. const CONTENT_WIDTH = '1400px'; // Ширина контентной области. Можно задать любое значение, например: 960px или 1200px
  18. const SHOW_SCROLL_UP_BUTTON = 1; // Показывать кнопку "вверх". 0 - выкл, 1 - вкл
  19. const ENABLE_AUTO_EXPAND_COMMENTS = 1; // Автоматически раскрывать общий список комментариев. 0 - выкл, 1 - вкл
  20.  
  21. 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"};
  22.  
  23. const transliterate = (word) => word.split('').map((char) => typeof dict[char] === 'undefined' ? char : dict[char]).join("");
  24.  
  25. const cn = (tagName, attrs = {}, childrenList = [], parentNode = null) => {
  26. const node = document.createElement(tagName);
  27.  
  28. if (typeof attrs === 'object') {
  29. for (const attrsKey in attrs) node.setAttribute(attrsKey, attrs[attrsKey]);
  30. }
  31.  
  32. if (Array.isArray(childrenList)) {
  33. childrenList.forEach(child => {
  34. node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
  35. });
  36. }
  37.  
  38. if (parentNode) {
  39. parentNode.appendChild(node);
  40. }
  41.  
  42. return node;
  43. };
  44.  
  45. const getDomElementAsync = (selector, timerLimit = 10000, debugMessage = '') => {
  46. return new Promise((resolve, reject) => {
  47. try {
  48. let timerId;
  49.  
  50. setTimeout(() => {
  51. if (timerId) {
  52. console.debug(`Время ожидания DOM элемента ${selector} истекло (${timerLimit / 1000}s)`);
  53. resolve(null);
  54. clearTimeout(timerId);
  55. }
  56. }, timerLimit);
  57.  
  58. const tick = () => {
  59. const element = document.querySelector(selector);
  60.  
  61. if (element) {
  62. clearTimeout(timerId);
  63. resolve(element);
  64. } else {
  65. timerId = setTimeout(tick, 100);
  66. }
  67. };
  68.  
  69. tick();
  70. } catch (e) {
  71. reject(e);
  72. }
  73. });
  74. };
  75.  
  76. const debounce = (func, wait) => {
  77. let timeout;
  78. return function (...args) {
  79. return new Promise(resolve => {
  80. clearTimeout(timeout);
  81. timeout = setTimeout(() => {
  82. timeout = null;
  83. Promise.resolve(func.apply(this, [...args])).then(resolve);
  84. }, wait);
  85. });
  86. };
  87. };
  88.  
  89. const observeUrlChange = async (onChange) => {
  90. await GM.setValue('currentUrl', window.location.href);
  91.  
  92. const onChangeHandler = async () => {
  93. const oldHref = await GM.getValue('currentUrl');
  94. const newHref = window.location.href;
  95.  
  96. if (oldHref !== newHref) {
  97. console.log('observeUrlChange');
  98.  
  99. await GM.setValue('currentUrl', newHref);
  100. onChange?.();
  101. }
  102. };
  103.  
  104. const debouncedOnChangeHandler = debounce(onChangeHandler, 500);
  105.  
  106. const observer = new MutationObserver(debouncedOnChangeHandler);
  107.  
  108. observer.observe(document.body, {
  109. childList: true,
  110. subtree: true,
  111. });
  112. };
  113.  
  114. const injectStyles = () => {
  115. const styles = `
  116. :root {
  117. ${USE_WIDE_LAYOUT ? '--layout-max-width: none;' : '--layout-max-width: ' + CONTENT_WIDTH}
  118. }
  119.  
  120. .sidebar__section._subs {
  121. margin: 24px 0;
  122. overflow: hidden;
  123. }
  124.  
  125. .sidebar-item._sub img.icon {
  126. width: 28px;
  127. border-radius: 50%;
  128. }
  129.  
  130. .sidebar-item._sub span {
  131. white-space: nowrap;
  132. overflow: hidden;
  133. text-overflow: ellipsis;
  134. min-width: 1px;
  135. }
  136.  
  137. /* перебиваем стили DTF */
  138. .layout,
  139. .header__layout {
  140. display: flex;
  141. gap: 24px;
  142.  
  143. ${SHOW_SCROLL_UP_BUTTON ? 'padding: 0 40px;' : ''}
  144. }
  145.  
  146. .header__left,
  147. .aside--left {
  148. width: 220px;
  149. flex-shrink: 0;
  150. }
  151.  
  152. .header__right,
  153. .aside--right {
  154. width: 300px;
  155. flex-shrink: 0;
  156. }
  157.  
  158. .header__right {
  159. justify-content: flex-end;
  160. margin-left: 0;
  161. }
  162.  
  163. .sidebar__main {
  164. display: flex;
  165. flex-direction: column;
  166. flex-shrink: 0;
  167. min-width: 1px;
  168. overflow: auto;
  169. }
  170.  
  171. .sidebar-item {
  172. flex-shrink: 0;
  173. }
  174.  
  175. .sidebar-editor-button {
  176. margin-top: 24px;
  177. }
  178.  
  179. .sidebar-editor-buttons {
  180. margin-top: auto;
  181. margin-bottom: 16px;
  182. }
  183.  
  184. .account-menu {
  185. visibility: hidden;
  186. }
  187.  
  188. body.dtf-subs-script-inited .account-menu {
  189. visibility: visible;
  190. }
  191.  
  192. /* кнопка "вверх" */
  193. .scroll-up-button {
  194. display: flex;
  195. justify-content: center;
  196. position: fixed;
  197. top: var(--layout-header-height);;
  198. left: 0;
  199. width: 40px;
  200. height: 100vh;
  201. padding-top: 10px;
  202. background-color: transparent;
  203. cursor: pointer;
  204. z-index: var(--layout-z-index-header);
  205. opacity: 0;
  206. pointer-events: none;
  207. transition: background-color 200ms ease-out, opacity 200ms ease-out;
  208. }
  209.  
  210. [data-theme="light"] .scroll-up-button:hover {
  211. background-color: var(--theme-color-brand-header);
  212. }
  213.  
  214. [data-theme="dark"] .scroll-up-button:hover {
  215. background-color: rgba(255, 255, 255, .1);
  216. }
  217.  
  218. .scroll-up-button use {
  219. display: none;
  220. }
  221.  
  222. .scroll-up-button.up use:nth-child(1) {
  223. display: block;
  224. }
  225.  
  226. .scroll-up-button.down use:nth-child(2) {
  227. display: block;
  228. }
  229.  
  230. .scroll-up-button.visible {
  231. opacity: 1;
  232. pointer-events: all;
  233. }
  234. `;
  235.  
  236. document.head.insertAdjacentHTML("beforeend", `<style type="text/css" id="dtfSubsStyles">${styles}</style>`)
  237. };
  238.  
  239. const fetchSubs = async (userId) => {
  240. const resp = await fetch(`https://api.dtf.ru/v2.5/subsite/subscriptions?subsiteId=${userId}`);
  241. const { result } = await resp.json();
  242.  
  243. return result.items;
  244. }
  245.  
  246. const getImageUrl = (uuid) => `https://leonardo.osnova.io/${uuid}/-/scale_crop/32x32/`;
  247.  
  248. const createSidebarItem = (name, imageId, href) => {
  249. const imgEl = cn('img', { class: 'icon', src: getImageUrl(imageId) });
  250. const nameEl = cn('span', {}, [name]);
  251. const result = cn('a', { class: 'sidebar-item _sub', href: transliterate(href), alt: name }, [imgEl, nameEl]);
  252.  
  253. return result;
  254. };
  255.  
  256. const createSidebarList = (items) => {
  257. const title = cn('div', { class: 'sidebar__title' }, ['Подписки']);
  258. const sidebarItems = items.map((item) => createSidebarItem(item.name, item.avatar.data.uuid, item.uri || `/id${item.id}`));
  259.  
  260. return cn('div', { class: 'sidebar__section _subs' }, [title, ...sidebarItems]);
  261. };
  262.  
  263. const getProfileUrl = async () => {
  264. const userButton = await getDomElementAsync('.account-button__inner');
  265. userButton.click();
  266.  
  267. const profileMenuItem = await getDomElementAsync('.user-card');
  268. userButton.click();
  269.  
  270. return profileMenuItem.href;
  271. }
  272.  
  273. const getUserId = async () => {
  274. const profileUrl = await getProfileUrl();
  275. const userId = profileUrl.split('/id')[1];
  276.  
  277. return userId || null;
  278. };
  279.  
  280. const injectSubscriptions = async () => {
  281. const userId = await getUserId();
  282.  
  283. document.body.classList.add('dtf-subs-script-inited');
  284.  
  285. if (!userId) {
  286. return;
  287. }
  288.  
  289. const subs = await fetchSubs(userId);
  290. const list = createSidebarList(subs);
  291.  
  292. const firstSidebarSection = await getDomElementAsync('.sidebar__section');
  293. firstSidebarSection.after(list);
  294. };
  295.  
  296. const runAutoExpandComments = () => {
  297. const expandComments = async () => {
  298. const isCommentsSlicePage = location.search.includes('comment=');
  299.  
  300. if (isCommentsSlicePage) {
  301. // На странице со срезом комментариев не запускаем автораскрытие общего списка.
  302. // Кнопка expandCommentsButton вместо раскрытия открывает страницу самого поста.
  303. // Из-за чего страница среза не отображается, а сразу происходит редирект.
  304. return;
  305. }
  306.  
  307. const expandCommentsButton = await getDomElementAsync('.comments-limit__expand');
  308. expandCommentsButton?.click();
  309. };
  310.  
  311. observeUrlChange(expandComments);
  312.  
  313. expandComments();
  314. };
  315.  
  316. const injectScrollUp = () => {
  317. const createScrollUpButton = () => {
  318. const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  319. svg.setAttribute('class','icon');
  320. svg.setAttribute('width','20px');
  321. svg.setAttribute('height','20px');
  322.  
  323. const iconUp = document.createElementNS('http://www.w3.org/2000/svg', 'use');
  324. iconUp.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href','#chevron_up');
  325.  
  326. const iconDown = document.createElementNS('http://www.w3.org/2000/svg', 'use');
  327. iconDown.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href','#chevron_down');
  328.  
  329. svg.appendChild(iconUp);
  330. svg.appendChild(iconDown);
  331.  
  332. return cn('div', { class: 'scroll-up-button' }, [svg], document.querySelector('body'));
  333. };
  334.  
  335. const scrollUpBtn = createScrollUpButton();
  336.  
  337. const setUpIcon = () => {
  338. scrollUpBtn.classList.remove('down');
  339. scrollUpBtn.classList.add('up');
  340. };
  341.  
  342. const setDownIcon = () => {
  343. scrollUpBtn.classList.remove('up');
  344. scrollUpBtn.classList.add('down')
  345. };
  346.  
  347. let prevScrollPosition = 0;
  348.  
  349. window.addEventListener('scroll', debounce(() => {
  350. if (window.scrollY) {
  351. scrollUpBtn.classList.add('visible');
  352.  
  353. setUpIcon();
  354. } else {
  355. setDownIcon();
  356. }
  357. }, 100));
  358.  
  359. scrollUpBtn.addEventListener('click', () => {
  360. if (window.scrollY) {
  361. prevScrollPosition = window.scrollY;
  362.  
  363. setDownIcon();
  364.  
  365. window.scrollTo({
  366. top: 0,
  367. left: 0,
  368. behavior: "smooth",
  369. });
  370. } else {
  371. setUpIcon();
  372.  
  373. window.scrollTo({
  374. top: prevScrollPosition,
  375. left: 0,
  376. behavior: "smooth",
  377. });
  378. }
  379. });
  380. };
  381.  
  382. const start = async () => {
  383. console.debug('DTF Enhancer started');
  384.  
  385. injectStyles();
  386. injectSubscriptions();
  387.  
  388. if (ENABLE_AUTO_EXPAND_COMMENTS) {
  389. runAutoExpandComments();
  390. }
  391.  
  392. if (SHOW_SCROLL_UP_BUTTON) {
  393. injectScrollUp();
  394. }
  395. };
  396.  
  397. const init = async () => {
  398. if (document.visibilityState === 'visible') {
  399. start();
  400. } else {
  401. // Для вкладок открытых в фоне запускаем скрипт после перехода на вкладку
  402. addEventListener("visibilitychange", (event) => {
  403. if (document.visibilityState === 'visible') {
  404. start();
  405. }
  406. }, { once: true });
  407. }
  408. };
  409.  
  410. init();
  411. })();