DTF Enhancer

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

  1. // ==UserScript==
  2. // @name DTF Enhancer
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.1.8
  5. // @description Выводит список подписок в сайдбаре и раскрывает список комментов
  6. // @author You
  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-subs {
  121. display: flex;
  122. flex-direction: column;
  123. overflow: auto;
  124. margin: 24px 0;
  125. }
  126.  
  127. .sidebar-item._sub img.icon {
  128. width: 24px;
  129. border-radius: 50%;
  130. }
  131.  
  132. .sidebar-item._sub span {
  133. white-space: nowrap;
  134. overflow: hidden;
  135. text-overflow: ellipsis;
  136. min-width: 1px;
  137. }
  138.  
  139. /* перебиваем стили DTF */
  140. .layout,
  141. .header__layout {
  142. display: flex;
  143. gap: 24px;
  144.  
  145. ${SHOW_SCROLL_UP_BUTTON ? 'padding: 0 40px;' : ''}
  146. }
  147.  
  148. .header__left,
  149. .aside--left {
  150. width: 220px;
  151. flex-shrink: 0;
  152. }
  153.  
  154. .header__right,
  155. .aside--right {
  156. width: 300px;
  157. flex-shrink: 0;
  158. }
  159.  
  160. .header__right {
  161. justify-content: flex-end;
  162. margin-left: 0;
  163. }
  164.  
  165. .sidebar__main {
  166. display: flex;
  167. flex-direction: column;
  168. flex-shrink: 0;
  169. min-width: 1px;
  170. overflow: auto;
  171. }
  172.  
  173. .sidebar-item {
  174. flex-shrink: 0;
  175. }
  176.  
  177. .sidebar-editor-button {
  178. margin-top: 24px;
  179. }
  180.  
  181. .sidebar-editor-buttons {
  182. margin-top: auto;
  183. margin-bottom: 16px;
  184. }
  185.  
  186. .account-menu {
  187. visibility: hidden;
  188. }
  189.  
  190. body.dtf-subs-script-inited .account-menu {
  191. visibility: visible;
  192. }
  193.  
  194. /* кнопка "вверх" */
  195. .scroll-up-button {
  196. display: flex;
  197. justify-content: center;
  198. position: fixed;
  199. top: var(--layout-header-height);;
  200. left: 0;
  201. width: 40px;
  202. height: 100vh;
  203. padding-top: 10px;
  204. background-color: transparent;
  205. cursor: pointer;
  206. z-index: var(--layout-z-index-header);
  207. opacity: 0;
  208. pointer-events: none;
  209. transition: background-color 200ms ease-out, opacity 200ms ease-out;
  210. }
  211.  
  212. [data-theme="light"] .scroll-up-button:hover {
  213. background-color: var(--theme-color-brand-header);
  214. }
  215.  
  216. [data-theme="dark"] .scroll-up-button:hover {
  217. background-color: rgba(255, 255, 255, .1);
  218. }
  219.  
  220. .scroll-up-button use {
  221. display: none;
  222. }
  223.  
  224. .scroll-up-button.up use:nth-child(1) {
  225. display: block;
  226. }
  227.  
  228. .scroll-up-button.down use:nth-child(2) {
  229. display: block;
  230. }
  231.  
  232. .scroll-up-button.visible {
  233. opacity: 1;
  234. pointer-events: all;
  235. }
  236. `;
  237.  
  238. document.head.insertAdjacentHTML("beforeend", `<style type="text/css" id="dtfSubsStyles">${styles}</style>`)
  239. };
  240.  
  241. const fetchSubs = async (userId) => {
  242. const resp = await fetch(`https://api.dtf.ru/v2.5/subsite/subscriptions?subsiteId=${userId}`);
  243. const { result } = await resp.json();
  244.  
  245. return result.items;
  246. }
  247.  
  248. const getImageUrl = (uuid) => `https://leonardo.osnova.io/${uuid}/-/scale_crop/32x32/`;
  249.  
  250. const createSidebarItem = (name, imageId, href) => {
  251. const imgEl = cn('img', { class: 'icon', src: getImageUrl(imageId) });
  252. const nameEl = cn('span', {}, [name]);
  253. const result = cn('a', { class: 'sidebar-item _sub', href: transliterate(href), alt: name }, [imgEl, nameEl]);
  254.  
  255. return result;
  256. };
  257.  
  258. const createSidebarList = (items) => {
  259. const sidebarItems = items.map((item) => {
  260. const href = item.uri || `/id${item.id}`;
  261.  
  262. return createSidebarItem(item.name, item.avatar.data.uuid, href);
  263. });
  264.  
  265. const title = cn('div', { class: 'sidebar__title' }, ['Подписки:']);
  266.  
  267. const listWrapper = cn('div', { class: 'sidebar__subs' }, sidebarItems);
  268.  
  269. return cn('div', { class: 'sidebar-subs' }, [title, listWrapper]);
  270. };
  271.  
  272. const getProfileUrl = async () => {
  273. const userButton = await getDomElementAsync('.account-button__inner');
  274. userButton.click();
  275.  
  276. const profileMenuItem = await getDomElementAsync('.user-card');
  277. userButton.click();
  278.  
  279. return profileMenuItem.href;
  280. }
  281.  
  282. const getUserId = async () => {
  283. const profileUrl = await getProfileUrl();
  284. const userId = profileUrl.split('/id')[1];
  285.  
  286. return userId || null;
  287. };
  288.  
  289. const injectSubscriptions = async () => {
  290. const userId = await getUserId();
  291.  
  292. document.body.classList.add('dtf-subs-script-inited');
  293.  
  294. if (!userId) {
  295. return;
  296. }
  297.  
  298. const subs = await fetchSubs(userId);
  299. const list = createSidebarList(subs);
  300.  
  301. const firstSidebarSection = await getDomElementAsync('.sidebar__section');
  302. firstSidebarSection.after(list);
  303. };
  304.  
  305. const runAutoExpandComments = () => {
  306. const expandComments = async () => {
  307. const isCommentsSlicePage = location.search.includes('comment=');
  308.  
  309. if (isCommentsSlicePage) {
  310. // На странице со срезом комментариев не запускаем автораскрытие общего списка.
  311. // Кнопка expandCommentsButton вместо раскрытия открывает страницу самого поста.
  312. // Из-за чего страница среза не отображается, а сразу происходит редирект.
  313. return;
  314. }
  315.  
  316. const expandCommentsButton = await getDomElementAsync('.comments-limit__expand');
  317. expandCommentsButton?.click();
  318. };
  319.  
  320. observeUrlChange(expandComments);
  321.  
  322. expandComments();
  323. };
  324.  
  325. const injectScrollUp = () => {
  326. const createScrollUpButton = () => {
  327. const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  328. svg.setAttribute('class','icon');
  329. svg.setAttribute('width','20px');
  330. svg.setAttribute('height','20px');
  331.  
  332. const iconUp = document.createElementNS('http://www.w3.org/2000/svg', 'use');
  333. iconUp.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href','#chevron_up');
  334.  
  335. const iconDown = document.createElementNS('http://www.w3.org/2000/svg', 'use');
  336. iconDown.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href','#chevron_down');
  337.  
  338. svg.appendChild(iconUp);
  339. svg.appendChild(iconDown);
  340.  
  341. return cn('div', { class: 'scroll-up-button' }, [svg], document.querySelector('body'));
  342. };
  343.  
  344. const scrollUpBtn = createScrollUpButton();
  345.  
  346. const setUpIcon = () => {
  347. scrollUpBtn.classList.remove('down');
  348. scrollUpBtn.classList.add('up');
  349. };
  350.  
  351. const setDownIcon = () => {
  352. scrollUpBtn.classList.remove('up');
  353. scrollUpBtn.classList.add('down')
  354. };
  355.  
  356. let prevScrollPosition = 0;
  357.  
  358. window.addEventListener('scroll', debounce(() => {
  359. if (window.scrollY) {
  360. scrollUpBtn.classList.add('visible');
  361.  
  362. setUpIcon();
  363. } else {
  364. setDownIcon();
  365. }
  366. }, 100));
  367.  
  368. scrollUpBtn.addEventListener('click', () => {
  369. if (window.scrollY) {
  370. prevScrollPosition = window.scrollY;
  371.  
  372. setDownIcon();
  373.  
  374. window.scrollTo({
  375. top: 0,
  376. left: 0,
  377. behavior: "smooth",
  378. });
  379. } else {
  380. setUpIcon();
  381.  
  382. window.scrollTo({
  383. top: prevScrollPosition,
  384. left: 0,
  385. behavior: "smooth",
  386. });
  387. }
  388. });
  389. };
  390.  
  391. const start = async () => {
  392. console.debug('DTF Enhancer started');
  393.  
  394. injectStyles();
  395. injectSubscriptions();
  396.  
  397. if (ENABLE_AUTO_EXPAND_COMMENTS) {
  398. runAutoExpandComments();
  399. }
  400.  
  401. if (SHOW_SCROLL_UP_BUTTON) {
  402. injectScrollUp();
  403. }
  404. };
  405.  
  406. const init = async () => {
  407. if (document.visibilityState === 'visible') {
  408. start();
  409. } else {
  410. // Для вкладок открытых в фоне запускаем скрипт после перехода на вкладку
  411. addEventListener("visibilitychange", (event) => {
  412. if (document.visibilityState === 'visible') {
  413. start();
  414. }
  415. }, { once: true });
  416. }
  417. };
  418.  
  419. init();
  420. })();