CMTT-MAGIC

Add some magic to CMTT projects!

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name            CMTT-MAGIC
// @name:ru         CMTT-MAGIC
// @version         1.0.1
// @description     Add some magic to CMTT projects!
// @description:ru  Добавить немного магии проектам CMTT!
// @namespace       https://kartoshka.com
// @author          Kartoshka
// @license         GPLv2+
// @match           https://vc.ru/*
// @match           https://tjournal.ru/*
// @match           https://dtf.ru/*
// @run-at          document-end
// @grant           GM_setValue
// @grant           GM.setValue
// @grant           GM_getValue
// @grant           GM.getValue
// ==/UserScript==

const COMMENTS_CONTAINER_SELECTOR = '.comments';
const COMMENTS_TABS_CONTAINER_SELECTOR = '.comments .comments__navigation .ui-tabs__content';
const COMMENTS_TITLE_SELECTOR = '.comments .comments__title';

const TAB_MODE_BUTTON_CLASS = 'magic-comments-mode-button';
const TAB_ACTION_BUTTON_CLASS = 'magic-comments-action-button';

const TAB_BUTTON_TEXT_CLASS = 'magic-comments-tab-button-text';

const OP_EXPAND_TOPLEVEL = 'expand-toplevel';
const OP_EXPAND_ALL = 'expand-all';
const OP_COLLAPSE_ALL = 'collapse-all';
const OP_COLLAPSE_ALL_SCROLL = 'collapse-all-scroll';
const OP_LOADMORE_ALL_NEAR_VIEWPORT = 'loadmore-all-near-viewport';
const OP_LOADMORE_FIRST_BELOW_VIEWPORT = 'loadmore-first-below-viewport';
const OP_LOAD_ALL_IF_PARTIAL = 'load-all-if-partial';

const ACTION_EXPAND = 'expand';
const ACTION_COLLAPSE = 'collapse';

const MODE_EXPAND_ON_SCROLL = 'expand-on-scroll';
const MODE_EXPAND_EARLY = 'expand-early';
const MODE_COLLAPSE_ALL = 'collapse-all';

const ACTIONS = [
  {
    value: ACTION_EXPAND,
    title: 'Развернуть',
    operations: [
      OP_EXPAND_ALL,
      {
        op: OP_LOAD_ALL_IF_PARTIAL,
        args: [{ afterLoadOperations: OP_EXPAND_ALL }],
      },
    ],
    newCurrentMode: MODE_EXPAND_EARLY,
  },
  {
    value: ACTION_COLLAPSE,
    title: 'Свернуть',
    operations: OP_COLLAPSE_ALL,
    newCurrentMode: MODE_COLLAPSE_ALL,
  },
];

const MODES = [
  {
    value: null,
    title: 'Не разворачиваются',
    initialOperations: null,
    initialNextAction: ACTION_EXPAND,
  },
  {
    value: MODE_EXPAND_ON_SCROLL,
    title: 'Разворачиваются',
    initialOperations: [
      OP_EXPAND_ALL,
      {
        op: OP_LOAD_ALL_IF_PARTIAL,
        args: [{ afterLoadOperations: OP_EXPAND_ALL }],
      },
    ],
    initialNextAction: ACTION_COLLAPSE,
  },
  {
    value: MODE_EXPAND_EARLY,
    title: 'Разворачиваются сразу',
    initialOperations: [
      OP_EXPAND_ALL,
      {
        op: OP_LOAD_ALL_IF_PARTIAL,
        args: [{ afterLoadOperations: OP_EXPAND_ALL }],
      },
    ],
    initialNextAction: ACTION_COLLAPSE,
  },
  {
    value: MODE_COLLAPSE_ALL,
    title: 'Сворачиваются все',
    initialOperations: OP_COLLAPSE_ALL_SCROLL,
    initialNextAction: ACTION_EXPAND,
  },
];

const DEFAULT_OPTIONS = {
  mode: null,
};

let windowLoaded = false;
let magicHappened = false;

let options;
let currentMode;
let nextAction;
let collapseProtected;
let restoreCommentScrollPosition;
let currentUrlPath;
let currentPageTitle;
let scrollAggregateTimer;
let throttledLoadMoreTimer;
let newPageDetectorTimer;
let waitForCommentsInitializedTimer;
let loadAllIfPartialCompleteTimer;

loadOptions().then(magic);
window.addEventListener('load', () => {
  windowLoaded = true;
  remagic();
});
window.addEventListener('popstate', startNewPageDetector);
window.addEventListener('click', startNewPageDetector);

/**
 *
 */
function magic() {
  if (!options) {
    return;
  }

  if (!windowLoaded) {
    return;
  }

  if (!getTabsContainer()) {
    return;
  }

  if (magicHappened) {
    return;
  }
  magicHappened = true;

  clean();
  init();
}

/**
 *
 */
function remagic() {
  magicHappened = false;
  magic();
}

/**
 *
 */
function init() {
  if (!options) {
    return;
  }

  currentMode = options.mode;
  nextAction = getModeParams(currentMode).initialNextAction;

  currentPageTitle = document.title;
  currentUrlPath = window.location.pathname;
  processOptionsBasedOnUrl();

  renderButtons();
  applyCurrentMode();
  runOperations(getModeParams(currentMode).initialOperations);
  addLoadAllIfPartialLinksClickHandler();
}

/**
 *
 */
function clean() {
  stopAllExpandRoutines();
  cleanCollapseProtected();
  restoreCommentScrollPosition = null;
  newPageDetectorTimer = stopTimer(newPageDetectorTimer);
  waitForCommentsInitializedTimer = stopTimer(waitForCommentsInitializedTimer);
  loadAllIfPartialCompleteTimer = stopTimer(loadAllIfPartialCompleteTimer);
}

/**
 *
 */
function startNewPageDetector() {
  stopTimer(newPageDetectorTimer);
  newPageDetectorTimer = setTimeout(newPageDetectorTick.bind(null, 0), 0);
}

/**
 *
 */
function newPageDetectorTick(iteration = 0) {
  newPageDetectorTimer = null;

  if (iteration > 20) {
    return;
  }

  const rescheduleWaitForCommentsInitialized = () => {
    stopTimer(waitForCommentsInitializedTimer);
    waitForCommentsInitializedTimer = setTimeout(waitForCommentsInitializedTick.bind(null, 0), 250);
  };

  if (currentUrlPath !== window.location.pathname) {
    currentUrlPath = window.location.pathname;
    rescheduleWaitForCommentsInitialized();
    return;
  }

  if (currentPageTitle !== document.title) {
    currentPageTitle = document.title;
    rescheduleWaitForCommentsInitialized();
    return;
  }

  newPageDetectorTimer = setTimeout(newPageDetectorTick.bind(null, iteration + 1), 250);
}

/**
 *
 */
function waitForCommentsInitializedTick(iteration = 0) {
  waitForCommentsInitializedTimer = null;

  if (iteration > 40) {
    return;
  }

  const reschedule = (timeout = 250) => {
    waitForCommentsInitializedTimer = setTimeout(
      waitForCommentsInitializedTick.bind(null, iteration + 1),
      timeout,
    );
  };

  const tabs = getTabsContainer();

  if (!tabs) {
    reschedule();
    return;
  }

  if (optionsModeButtonExists(tabs)) {
    reschedule();
    return;
  }

  (async () => {
    await sleep(1);

    remagic();

    await sleep(250);

    if (!waitForCommentsInitializedTimer) {
      // Lets keep trying even after remagic
      reschedule();
    }
  })();
}

/**
 *
 */
function processOptionsBasedOnUrl() {
  const query = new URLSearchParams(window.location.search);
  const id = query.get('comment');

  if (id) {
    addCollapseProtected(id);
    restoreCommentScrollPosition = id;
  }
}

/**
 *
 */
function renderButtons() {
  const tabs = getTabsContainer();

  if (!tabs) {
    return;
  }

  renderOptionsModeButton(tabs);
  renderActionButton(tabs);
}

/**
 *
 */
function renderOptionsModeButton(container = getTabsContainer()) {
  if (!options) {
    return;
  }

  // This button always shows mode stored in options (not the currentMode)
  const modeParams = getModeParams(options.mode, 0);
  const nextModeParams = getModeParamsByIndex(findModeIndex(options.mode) + 1);

  const button = ensureTabButton(TAB_MODE_BUTTON_CLASS, container);

  setTabButtonText(button, modeParams.title);
  setTabButtonOnClick(button, setOptionsMode.bind(null, nextModeParams.value));

  setTabButtonTextStyle(
    button,
    'padding:3px 7px;border:1px dotted #888;border-radius:4px;opacity:.8;user-select:none',
  );
}

/**
 *
 */
function optionsModeButtonExists(container = getTabsContainer()) {
  return !!container.querySelector(`.${TAB_MODE_BUTTON_CLASS}`);
}

/**
 *
 */
function renderActionButton(container = getTabsContainer()) {
  const actionParams = getActionParams(nextAction);

  const button = ensureTabButton(TAB_ACTION_BUTTON_CLASS, container);

  setTabButtonText(button, actionParams?.title);
  setTabButtonOnClick(button, runAction.bind(null, nextAction));

  setTabButtonTextStyle(button, 'opacity:.7;user-select:none');
}

/**
 *
 */
function setOptionsMode(newMode) {
  if (!options) {
    return;
  }

  if (options.mode === newMode) {
    return;
  }

  options.mode = newMode;

  saveOptions().then(remagic);
}

/**
 *
 */
function runAction(action) {
  const actionParams = getActionParams(action, 0);
  const nextActionParams = getActionParamsByIndex(findActionIndex(action) + 1);

  currentMode = actionParams.newCurrentMode;
  nextAction = nextActionParams.value;

  renderActionButton();
  applyCurrentMode();
  runOperations(actionParams.operations);
}

/**
 *
 */
function applyCurrentMode() {
  switch (currentMode) {
    case MODE_EXPAND_ON_SCROLL:
      applyExpandOnScrollMode();
      break;
    case MODE_EXPAND_EARLY:
      applyExpandEarlyMode();
      break;
    case MODE_COLLAPSE_ALL:
      applyCollapseAllMode();
      break;
  }
}

/**
 *
 */
function applyExpandOnScrollMode() {
  runOperations(OP_LOADMORE_ALL_NEAR_VIEWPORT);
  document.addEventListener('scroll', scrollHandler);
}

/**
 *
 */
function applyExpandEarlyMode() {
  runOperations(OP_LOADMORE_ALL_NEAR_VIEWPORT);
  document.addEventListener('scroll', scrollHandler);
  scheduleThrottledLoadMore();
}

/**
 *
 */
function applyCollapseAllMode() {
  stopAllExpandRoutines();
}

/**
 *
 */
function stopAllExpandRoutines() {
  document.removeEventListener('scroll', scrollHandler);
  scrollAggregateTimer = stopTimer(scrollAggregateTimer);
  throttledLoadMoreTimer = stopTimer(throttledLoadMoreTimer);
}

/**
 *
 */
function scrollHandler() {
  if (!scrollAggregateTimer) {
    scrollAggregateTimer = setTimeout(() => {
      scrollAggregateTimer = null;
      runOperation(OP_LOADMORE_ALL_NEAR_VIEWPORT);
    }, 300);
  }
}

/**
 *
 */
function scheduleThrottledLoadMore() {
  throttledLoadMoreTimer = setTimeout(throttledLoadMoreTick, 3000);
}

/**
 *
 */
function throttledLoadMoreTick() {
  if (runOperation(OP_LOADMORE_FIRST_BELOW_VIEWPORT)) {
    scheduleThrottledLoadMore();
  }
}

/**
 *
 */
function runOperations(operations) {
  if (!operations) {
    return undefined;
  }
  return Array.isArray(operations)
    ? operations.map((item) => (!!item ? runOperation(item) : undefined))
    : runOperation(operations);
}

/**
 *
 */
function runOperation(operation) {
  let operationArgs = [];

  if (operation?.op) {
    if (operation.hasOwnProperty('args')) {
      operationArgs = Array.isArray(operation.args) ? operation.args : [operation.args];
    }
    operation = operation.op;
  } else if (typeof operation !== 'string') {
    throw new Error('Operation must be a string');
  }

  switch (operation) {
    case OP_EXPAND_TOPLEVEL:
      return expandCollapsedUpToLevel(1);
    case OP_EXPAND_ALL:
      return expandAllCollapsed();
    case OP_COLLAPSE_ALL:
      return collapseAllExceptProtected();
    case OP_COLLAPSE_ALL_SCROLL:
      return collapseAllExceptProtectedScroll();
    case OP_LOADMORE_ALL_NEAR_VIEWPORT:
      return loadMoreAllNearViewport();
    case OP_LOADMORE_FIRST_BELOW_VIEWPORT:
      return loadMoreFirstBelowViewport();
    case OP_LOAD_ALL_IF_PARTIAL:
      return loadAllIfPartial(...operationArgs);
    default:
      throw new Error(`Unknown operation: ${operation}`);
  }
}

/**
 *
 */
function expandCollapsedUpToLevel(level) {
  let counter = 0;

  for (let i = 1; i <= level; i++) {
    counter += clickOnNodesPreservingScroll(`[data-level="${i}"] .comment__expand-branch--visible`);
  }

  return counter;
}

/**
 *
 */
function expandAllCollapsed() {
  return clickOnNodesPreservingScroll('.comment__expand-branch--visible');
}

/**
 *
 */
function collapseAllExceptProtected() {
  doCollapseComments('', true, false);
}

/**
 *
 */
function collapseAllExceptProtectedScroll() {
  doCollapseComments('', true, true);
}

/**
 *
 */
function doCollapseComments(filterSelector = '', exceptProtected = true, updateScroll = true) {
  const container = getCommentsContainer();

  let filter = filterSelector;

  if (exceptProtected) {
    filter += generateCollapseProtectedSelector();
  }

  const collapseSelector = `.comment${filter} .comment__branch--no-border`;

  if (!updateScroll) {
    return clickOnNodesPreservingScroll(collapseSelector);
  }

  (async () => {
    const windowHeight = window.innerHeight || document.documentElement.clientHeight;

    await waitForSameValueWithinTime(() => window.scrollY, 1, 1);

    let commentIdScrollTo;
    let commentScrollTo;
    let positionFromBottomScrollTo;

    if (restoreCommentScrollPosition) {
      await waitForSameValueWithinTime(() => window.scrollY, 25, 5);

      commentIdScrollTo = restoreCommentScrollPosition;
      restoreCommentScrollPosition = null;

      const el = getCommentElementByCommentId(commentIdScrollTo);

      if (el) {
        const top = getElementViewportRelativeTop(el);
        if (top >= 500 || top <= -500) {
          commentIdScrollTo = null;
        }
      } else {
        commentIdScrollTo = null;
      }
    }

    if (commentIdScrollTo) {
      commentScrollTo = getCommentElementByCommentId(commentIdScrollTo, container);
    } else {
      commentScrollTo = findFirstCommentElementBeginsWithinViewport();
      if (typeof commentScrollTo === 'number') {
        if (commentScrollTo < 0) {
          // Comments are below the viewport, collapse should not affect scroll position
          commentScrollTo = null;
        } else {
          // Comments are above the viewport, it's better to scroll to position from bottom instead
          positionFromBottomScrollTo = document.documentElement.scrollHeight - window.scrollY;
          commentScrollTo = null;
        }
      }
    }

    if (!commentScrollTo && !positionFromBottomScrollTo) {
      return clickOnNodesPreservingScroll(collapseSelector);
    }

    let oldTop = null;

    if (commentScrollTo) {
      oldTop = getElementViewportRelativeTop(commentScrollTo);
    }

    const scrollToNewPos = () => {
      let newScrollY = null;

      if (positionFromBottomScrollTo) {
        newScrollY = document.documentElement.scrollHeight - positionFromBottomScrollTo;
      } else if (oldTop !== null && commentScrollTo) {
        if (commentScrollTo?.classList?.contains('comment--collapsed')) {
          // If commentScrollTo is collapsed now then scroll to its first visible ancestor
          try {
            for (const ancestorId of getCommentAncestors(
              getCommentIdByCommentElement(commentScrollTo),
            )) {
              const ancestorEl = getCommentElementByCommentId(ancestorId);
              if (!ancestorEl?.classList || ancestorEl?.classList?.contains('comment--collapsed')) {
                continue;
              }
              newScrollY =
                window.scrollY + getElementViewportRelativeTop(ancestorEl) - windowHeight / 2 + 100;
              break;
            }
          } catch (e) {
            return;
          }
        } else {
          newScrollY = window.scrollY + getElementViewportRelativeTop(commentScrollTo) - oldTop;
        }
      }

      if (typeof newScrollY !== 'number') {
        return null;
      }

      newScrollY = Math.floor(newScrollY);

      window.scrollTo(0, newScrollY);

      return newScrollY;
    };

    const result = clickOnNodes(collapseSelector);

    let scrolledTo = scrollToNewPos();

    await sleep(1);

    if (scrolledTo !== window.scrollY) {
      scrollToNewPos();
    }

    return result;
  })();
}

/**
 *
 */
function generateCollapseProtectedSelector(container = getCommentsContainer()) {
  if (!(collapseProtected instanceof Set) || !collapseProtected.size) {
    return '';
  }

  const allDescendents = new Set();
  const processedRoots = new Set();

  for (const protectedId of [...collapseProtected]) {
    const rootId = getCommentRootId(protectedId);

    if (processedRoots.has(rootId)) {
      continue;
    }

    processedRoots.add(rootId);

    for (const descendentId of getCommentDescendants(rootId)) {
      allDescendents.add(descendentId);
    }
  }

  const branchClosings = new Set();

  for (const descendentId of [...allDescendents]) {
    if (
      container.querySelector(`.comment[data-id="${descendentId}"] .comment__branch--no-border`)
    ) {
      branchClosings.add(descendentId);
    }
  }

  if (branchClosings.size > 1000) {
    return '';
  }

  return [...branchClosings].map((item) => `:not([data-id="${item}"])`).join('');
}

/**
 *
 */
function loadMoreAllNearViewport() {
  const inViewport = [];
  const nearViewport = [];

  iterateLoadMoreNodes((node) => {
    const top = getElementViewportRelativeTop(node);
    const bottom = getElementViewportRelativeBottom(node);

    if (top > 0) {
      if (bottom > 0) {
        inViewport.push(node);
      } else if (bottom > -200) {
        nearViewport.push(node);
      } else if (bottom > -500 && !nearViewport.length) {
        nearViewport.push(node);
      }
    }
  });

  for (const node of new Set([...inViewport, ...nearViewport])) {
    doClickLoadMoreNode(node);
  }
}

/**
 *
 */
function loadMoreFirstBelowViewport() {
  return !!iterateLoadMoreNodes((node) => {
    const top = getElementViewportRelativeTop(node);
    const bottom = getElementViewportRelativeBottom(node);

    if (top > 0 && bottom < 0) {
      doClickLoadMoreNode(node);
      return true;
    }
  });
}

/**
 *
 */
function iterateLoadMoreNodes(fn) {
  const selectors = ['.comment__load-more:not(.comment__load-more--waiting)', '.comments__more'];

  const container = getCommentsContainer();

  for (const selector of selectors) {
    for (const node of container.querySelectorAll(
      `${selector}:not(.magic-comments-load-waiting)`,
    )) {
      if (fn(node) === true) {
        return true;
      }
    }
  }
}

/**
 *
 */
function doClickLoadMoreNode(node) {
  if (node?.classList) {
    node.classList.add('magic-comments-load-waiting');
  }
  node.click();
}

/**
 *
 */
function addLoadAllIfPartialLinksClickHandler() {
  for (const link of document.querySelectorAll(
    `${COMMENTS_CONTAINER_SELECTOR} .comments__link_to_all a`,
  )) {
    link.addEventListener('click', loadAllIfPartialLinksClickHandler);
  }
}

/**
 *
 */
function loadAllIfPartialLinksClickHandler() {
  let operations;

  switch (currentMode) {
    case MODE_EXPAND_ON_SCROLL:
    case MODE_EXPAND_EARLY:
      operations = OP_EXPAND_ALL;
      break;
    case MODE_COLLAPSE_ALL:
      operations = OP_COLLAPSE_ALL_SCROLL;
      break;
    default:
      operations = [];
  }

  startLoadAllIfPartialCompleteTimer({
    afterLoadOperations: operations,
  });
}

/**
 *
 */
function loadAllIfPartial(...args) {
  const link = document.querySelector(`${COMMENTS_CONTAINER_SELECTOR} .comments__link_to_all a`);

  if (!link) {
    return;
  }

  link.click();

  startLoadAllIfPartialCompleteTimer(...args);
}

/**
 *
 */
function startLoadAllIfPartialCompleteTimer(...args) {
  stopTimer(loadAllIfPartialCompleteTimer);
  loadAllIfPartialCompleteTimer = setTimeout(
    loadAllIfPartialCompleteTick.bind(null, 0, ...args),
    250,
  );
}

/**
 *
 */
function loadAllIfPartialCompleteTick(iteration = 0, ...args) {
  loadAllIfPartialCompleteTimer = null;

  if (iteration > 50) {
    return;
  }

  const tabs = getTabsContainer();

  if (tabs) {
    if (!tabs.querySelector('.comments__link_to_all')) {
      setTimeout(loadAllIfPartialCompleteCallback.bind(null, ...args), 1000);
    }
    return;
  }

  loadAllIfPartialCompleteTimer = setTimeout(
    loadAllIfPartialCompleteTick.bind(null, iteration + 1, ...args),
    250,
  );
}

/**
 *
 */
function loadAllIfPartialCompleteCallback(params) {
  if (params?.afterLoadOperations) {
    runOperations(params.afterLoadOperations);
  }
}

/**
 *
 */
function getCommentElementByCommentId(id, container = getCommentsContainer()) {
  return container.querySelector(`.comment[data-id="${id}"]`);
}

/**
 *
 */
function getCommentIdByCommentElement(element) {
  if (typeof element?.getAttribute !== 'function') {
    return undefined;
  }
  return element.getAttribute('data-id') | undefined;
}

/**
 *
 */
function getNthCommentElement(n, filter = null, comments = null) {
  return filterCommentElements(filter, comments)?.[n - 1];
}

/**
 *
 */
function getLastCommentElement(filter = null, comments = null) {
  comments = filterCommentElements(filter, comments);
  return comments?.[comments.length - 1];
}

/**
 *
 */
function getCommentElementsCount(filter = null, comments = null) {
  return filterCommentElements(filter, comments).length;
}

/**
 *
 */
function binarySearchCommentElement(fn, filter = null, comments = null) {
  comments = filterCommentElements(filter, comments);

  if (!comments.length) {
    return undefined;
  }

  let l = 0;
  let r = comments.length - 1;

  while (l <= r) {
    const i = Math.floor((l + r) / 2);

    const comment = comments?.[i];

    if (!comment) {
      return undefined;
    }

    const result = fn(comment, i, comments);

    if (result === -1) {
      r = i - 1;
    } else if (result === 1) {
      l = i + 1;
    } else {
      return comment;
    }
  }

  return undefined;
}

/**
 *
 */
function getAllCommentElements() {
  return filterCommentElements(null);
}

/**
 *
 */
function filterCommentElements(filter, comments) {
  if (!comments) {
    comments = document.getElementsByClassName('comment');

    if (!comments) {
      throw new Error('Function getElementsByClassName() returned unexpected result');
    }
  }

  // Convert to array if it's an iterable (e.g. Set)
  comments = [...comments];

  if (typeof filter !== 'function') {
    return comments;
  }

  if (comments.length) {
    comments = comments.filter(filter);
  }

  return comments;
}

/**
 *
 */
function visibleCommentsOnlyFilter(comment) {
  if (!comment?.classList) {
    return false;
  }
  return !comment.classList.contains('comment--collapsed');
}

/**
 *
 */
function findFirstCommentElementBeginsWithinViewport() {
  const comments = filterCommentElements(visibleCommentsOnlyFilter);

  if (!comments.length) {
    return undefined;
  }

  const windowHeight = window.innerHeight || document.documentElement.clientHeight;

  let top = getElementViewportRelativeTop(comments[0]);

  if (top >= 0) {
    if (top < windowHeight) {
      return comments[0];
    }
    // There is no comment within the viewport (all of them are below it)
    return top;
  }

  top = getElementViewportRelativeBottom(comments[comments.length - 1]);

  if (top < 0) {
    // All comments are above the viewport
    return top;
  }

  return binarySearchCommentElement(
    (comment, index, comments) => {
      let top = getElementViewportRelativeTop(comment);

      if (top < 0) {
        if (
          comments?.[index + 1] &&
          getElementViewportRelativeTop(comments?.[index + 1]) >= 0 &&
          getElementViewportRelativeTop(comments?.[index + 1]) < windowHeight
        ) {
          return -1;
        } else {
          // A case when comment is started above viewport and took the entire window height
          return 0;
        }
      } else if (top > windowHeight) {
        return 1;
      } else if (
        comments?.[index - 1] &&
        getElementViewportRelativeTop(comments?.[index - 1]) > 0
      ) {
        return -1;
      }

      return 0;
    },
    null,
    comments,
  );
}

/**
 *
 */
function getCommentsCountByAttr() {
  const title = document.querySelector(COMMENTS_TITLE_SELECTOR);

  if (!title) {
    return undefined;
  }

  const count = parseInt(title.getAttribute('data-count'));

  return typeof count === 'number' ? count : undefined;
}

/**
 *
 */
function getCommentParentId(id) {
  id = parseInt(id);

  if (!id) {
    throw new Error('Invalid comment id');
  }

  const comment = document.querySelector(
    `${COMMENTS_CONTAINER_SELECTOR} .comment[data-id="${id}"]`,
  );

  if (!comment) {
    throw new Error(`Comment "${id}" not found`);
  }

  return parseInt(comment.getAttribute('data-reply_to')) || null;
}

/**
 *
 */
function getSiblingCommentId(id) {
  id = parseInt(id);

  if (!id) {
    throw new Error('Invalid comment id');
  }

  const comment = document.querySelector(
    `${COMMENTS_CONTAINER_SELECTOR} .comment[data-id="${id}"] + .comment`,
  );

  if (!comment) {
    return null;
  }

  return parseInt(comment.getAttribute('data-id')) || null;
}

/**
 *
 */
function getCommentAncestors(id) {
  const ancestors = [];

  let loopDetector = 0;
  let parentId = id;

  while (parentId && ++loopDetector < 1000) {
    parentId = getCommentParentId(parentId);
    if (parentId) {
      ancestors.push(parentId);
    }
  }

  return ancestors;
}

/**
 *
 */
function isCommentAncestor(id, ancestorId) {
  ancestorId = parseInt(ancestorId);

  let loopDetector = 0;
  let parentId = id;

  while (parentId && ++loopDetector < 1000) {
    parentId = getCommentParentId(parentId);
    if (parentId && parentId === ancestorId) {
      return true;
    }
  }

  return false;
}

/**
 *
 */
function getCommentRootId(id) {
  id = parseInt(id);

  if (!id) {
    throw new Error('Invalid comment id');
  }

  const ancestors = getCommentAncestors(id);

  return ancestors.length ? ancestors[ancestors.length - 1] : id;
}

/**
 *
 */
function getCommentDescendants(id) {
  const descendants = [];

  let loopDetector = 0;
  let siblingId = id;

  while (siblingId && ++loopDetector < 10000) {
    siblingId = getSiblingCommentId(siblingId);
    if (siblingId) {
      if (!isCommentAncestor(siblingId, id)) {
        break;
      }
      descendants.push(siblingId);
    }
  }

  return descendants;
}

/**
 *
 */
function ensureTabButton(className, container = getTabsContainer()) {
  let button = container.querySelector(`.${className}`);

  if (!button) {
    button = createTabButton();
    button.classList.add(className);
    container.appendChild(button);
  }

  return button;
}

/**
 *
 */
function createTabButton() {
  const button = document.createElement('div');
  button.classList.add('ui-tab');

  const buttonLabel = document.createElement('span');
  buttonLabel.classList.add('ui-tab__label');

  const buttonMagicText = document.createElement('span');
  buttonMagicText.classList.add(TAB_BUTTON_TEXT_CLASS);

  buttonLabel.appendChild(buttonMagicText);
  button.appendChild(buttonLabel);

  return button;
}

/**
 *
 */
function setTabButtonText(button, text) {
  setTabButtonTextProperty(button, 'textContent', text);
}

/**
 *
 */
function setTabButtonTextStyle(button, style) {
  setTabButtonTextProperty(button, 'style', style);
}

/**
 *
 */
function setTabButtonTextProperty(button, property, value) {
  if (!button) {
    return;
  }

  const textEl = button.querySelector(`.${TAB_BUTTON_TEXT_CLASS}`);

  if (!textEl) {
    return;
  }

  textEl[property] = value;
}

/**
 *
 */
function addTabButtonClass(button, className) {
  if (button?.classList) {
    button.classList.add(className);
  }
}

/**
 *
 */
function removeTabButtonClass(button, className) {
  if (button?.classList) {
    button.classList.remove(className);
  }
}

/**
 *
 */
function setTabButtonOnClick(button, callback) {
  if (button) {
    button.onclick = callback;
  }
}

/**
 *
 */
function clickOnNodes(selector, filter = null, container = getCommentsContainer()) {
  if (!container) {
    return 0;
  }

  if (filter && typeof filter !== 'function') {
    throw new Error('Node filter must be a function');
  }

  let counter = 0;

  for (const node of container.querySelectorAll(selector)) {
    if (filter && !filter(node)) {
      continue;
    }
    node.click();
    ++counter;
  }

  return counter;
}

/**
 *
 */
function clickOnNodesPreservingScroll(selector, filter = null, container = getCommentsContainer()) {
  const pos = window.scrollY;
  const result = clickOnNodes(selector, filter, container);
  window.scrollTo(0, pos);
  return result;
}

/**
 *
 */
function getCommentsContainer() {
  return document.querySelector(COMMENTS_CONTAINER_SELECTOR);
}

/**
 *
 */
function getTabsContainer() {
  return document.querySelector(COMMENTS_TABS_CONTAINER_SELECTOR);
}

/**
 *
 */
function getElementViewportRelativeTop(element) {
  if (!element?.getBoundingClientRect) {
    return undefined;
  }
  return element.getBoundingClientRect().top;
}

/**
 *
 */
function getElementViewportRelativeBottom(element) {
  if (!element?.getBoundingClientRect) {
    return undefined;
  }
  const windowHeight = window.innerHeight || document.documentElement.clientHeight;
  const rect = element.getBoundingClientRect();
  return windowHeight - (rect.top + rect.height);
}

/**
 *
 */
function addCollapseProtected(id) {
  if (!(collapseProtected instanceof Set)) {
    collapseProtected = new Set();
  }

  id = parseInt(id);

  if (id) {
    collapseProtected.add(id);
  }
}

/**
 *
 */
function removeCollapseProtected(id) {
  if (!(collapseProtected instanceof Set)) {
    return;
  }

  id = parseInt(id);

  if (id) {
    collapseProtected.remove(id);
  }
}

/**
 *
 */
function cleanCollapseProtected() {
  collapseProtected = null;
}

/**
 *
 */
async function waitForSameValueWithinTime(getter, delay, times, allowedValues) {
  if (typeof allowedValues !== 'undefined') {
    if (!Array.isArray(allowedValues)) {
      allowedValues = [allowedValues];
    }
  }

  const checkValueIsAllowed = (value) => {
    if (!Array.isArray(allowedValues)) {
      return;
    }
    if (allowedValues.indexOf(value) === -1) {
      throw new Error(`Value "${value}" is not allowed`);
    }
  };

  let i = 0;
  let val = getter();

  checkValueIsAllowed(val);

  while (++i <= times) {
    await sleep(delay);

    const newval = getter();

    checkValueIsAllowed(newval);

    if (newval !== val) {
      val = newval;
      i = 0;
    }
  }

  return val;
}

/**
 *
 */
function stopObserver(observer) {
  if (observer) {
    observer.disconnect();
  }
  return null;
}

/**
 *
 */
function stopTimer(timer) {
  if (timer) {
    clearTimeout(timer);
  }
  return null;
}

/**
 *
 */
async function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

/**
 *
 */
function findModeIndex(mode) {
  return findParamsArrayEntryIndex(MODES, mode);
}

/**
 *
 */
function getModeParams(mode, defaultIndex = 0) {
  return getParamsArrayEntry(MODES, mode, defaultIndex);
}

/**
 *
 */
function getModeParamsByIndex(index, defaultIndex = 0) {
  return getParamsArrayEntryByIndex(MODES, index, defaultIndex);
}

/**
 *
 */
function findActionIndex(action) {
  return findParamsArrayEntryIndex(ACTIONS, action);
}

/**
 *
 */
function getActionParams(action, defaultIndex = 0) {
  return getParamsArrayEntry(ACTIONS, action, defaultIndex);
}

/**
 *
 */
function getActionParamsByIndex(index, defaultIndex = 0) {
  return getParamsArrayEntryByIndex(ACTIONS, index, defaultIndex);
}

/**
 *
 */
function findParamsArrayEntryIndex(arr, value) {
  return arr.findIndex((item) => item.value === value);
}

/**
 *
 */
function getParamsArrayEntry(arr, value, defaultIndex = 0) {
  return arr.find((item) => item.value === value) || arr[defaultIndex];
}

/**
 *
 */
function getParamsArrayEntryByIndex(arr, index, defaultIndex = 0) {
  return arr?.[index] || arr[defaultIndex];
}

/**
 *
 */
async function loadOptions() {
  options = await loadCurrentHostParam('options', DEFAULT_OPTIONS);
}

/**
 *
 */
async function saveOptions() {
  if (!options) {
    return false;
  }
  await saveCurrentHostParam('options', options);
  return true;
}

/**
 *
 */
function formatStorageParamName(name) {
  return name.replace(/[^a-z0-9]/i, '_');
}

/**
 *
 */
async function loadParamFromStorage(param, defaultValue) {
  if (typeof GM?.getValue === 'function') {
    return await GM.getValue(formatStorageParamName(param), defaultValue);
  } else if (typeof GM_getValue === 'function') {
    return GM_getValue(formatStorageParamName(param), defaultValue);
  } else {
    throw new Error('Script requires GM.getValue() or GM_getValue() to load parameters');
  }
}

/**
 *
 */
async function saveParamToStorage(param, value) {
  if (typeof value !== 'string') {
    throw new Error('Storage can contain only string values');
  }

  if (typeof GM?.setValue === 'function') {
    await GM.setValue(formatStorageParamName(param), value);
  } else if (typeof GM_setValue === 'function') {
    GM_setValue(formatStorageParamName(param), value);
  } else {
    throw new Error('Script requires GM.setValue() or GM_setValue() to save parameters');
  }
}

/**
 *
 */
async function loadParamAsJsonFromStorage(param, defaultValue) {
  const value = await loadParamFromStorage(param, null);
  return typeof value === 'string' ? JSON.parse(value) : defaultValue;
}

/**
 *
 */
async function saveParamAsJsonToStorage(param, value) {
  return saveParamToStorage(param, JSON.stringify(value));
}

/**
 *
 */
async function loadCurrentHostParam(name, defaultValue) {
  return loadParamAsJsonFromStorage(`${window.location.host}${name}`, defaultValue);
}

/**
 *
 */
async function saveCurrentHostParam(name, value) {
  return saveParamAsJsonToStorage(`${window.location.host}${name}`, value);
}