// ==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);
}