DTF Enhancer

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

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

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