/**
* Singleton that stores and changes the overall state of the page, initiating boot processes and
* reacting to events.
*
* @module controller
*/
import Autocomplete from './Autocomplete';
import BootProcess from './BootProcess';
import Comment from './Comment';
import CommentForm from './CommentForm';
import DtSubscriptions from './DtSubscriptions';
import ElementsTreeWalker from './ElementsTreeWalker';
import LegacySubscriptions from './LegacySubscriptions';
import Parser from './Parser';
import addCommentLinks from './addCommentLinks';
import cd from './cd';
import commentFormRegistry from './commentFormRegistry';
import commentRegistry from './commentRegistry';
import debug from './debug';
import init from './init';
import navPanel from './navPanel';
import notifications from './notifications';
import pageRegistry from './pageRegistry';
import sectionRegistry from './sectionRegistry';
import settings from './settings';
import toc from './toc';
import updateChecker from './updateChecker';
import { getUserInfo } from './utils-api';
import { defined, definedAndNotNull, flat, getLastArrayElementOrSelf, isHeadingNode, isInline, isProbablyTalkPage, sleep } from './utils-general';
import { mixEventEmitterIntoObject } from './utils-oojs';
import { copyText, getVisibilityByRects, skin$, wrapHtml } from './utils-window';
import Worker from './worker-gate';
export default {
content: {},
scrollData: { offset: null },
autoScrolling: false,
isUpdateThreadLinesHandlerAttached: false,
lastScrollX: 0,
originalPageTitle: document.title,
lastCheckedRevisionId: null,
addedCommentCount: 0,
areRelevantCommentsAdded: false,
relevantAddedCommentIds: null,
commentsNotifiedAbout: [],
isObstructingElementHoveredCached: false,
/**
* _For internal use._ Assign some properties required by the controller - those which are not
* known from the beginning - and run the boot process (on talk page or comment links page).
*/
init() {
this.$content ||= $('#mw-content-text');
if (cd.g.isMobile) {
$(document.body).addClass('cd-mobile');
}
// Not constants: `go()` may run a second time, see `app~maybeAddFooterSwitcher()`.
const isEnabledInQuery = /[?&]cdtalkpage=(1|true|yes|y)(?=&|$)/.test(location.search);
const isDisabledInQuery = /[?&]cdtalkpage=(0|false|no|n)(?=&|$)/.test(location.search);
// See `.isDefinitelyTalkPage()`
this.definitelyTalkPage = Boolean(
isEnabledInQuery ||
// `.cd-talkPage` is used as a last resort way to make CD parse the page, as opposed to using
// the list of supported namespaces and page white/black list in the configuration. With this
// method, there won't be "comment" links for edits on pages that list revisions such as the
// watchlist.
this.$content.find('.cd-talkPage').length ||
(
($('#ca-addsection').length || cd.g.pageWhitelistRegexp?.test(cd.g.pageName)) &&
!cd.g.pageBlacklistRegexp?.test(cd.g.pageName)
)
);
// See `.isArticlePageTalkPage()`
this.articlePageTalkPage = (
(!mw.config.get('wgIsRedirect') || !this.isCurrentRevision()) &&
!this.$content.find('.cd-notTalkPage').length &&
(isProbablyTalkPage(cd.g.pageName, cd.g.namespaceNumber) || this.definitelyTalkPage) &&
// Undocumented setting
!(typeof cdOnlyRunByFooterLink !== 'undefined' && window.cdOnlyRunByFooterLink)
);
// See `.isDiffPage()`
this.diffPage = /[?&]diff=[^&]/.test(location.search);
this.talkPage = Boolean(
mw.config.get('wgIsArticle') &&
!isDisabledInQuery &&
(isEnabledInQuery || this.articlePageTalkPage)
);
this.bootOnTalkPage();
this.bootOnCommentLinksPage();
},
/**
* Setup the controller for use in the current boot process. (Executed at every page reload.)
*
* @param {string} pageHtml HTML to update the page with.
*/
setup(pageHtml) {
// RevisionSlider replaces the `#mw-content-text` element.
if (!this.$content[0]?.parentNode) {
this.$content = $('#mw-content-text');
}
if (pageHtml) {
const div = document.createElement('div');
div.innerHTML = pageHtml;
this.rootElement = div.firstChild;
this.$root = $(this.rootElement);
} else {
// There can be more than one `.mw-parser-output` child, e.g. on talk pages of IP editors.
this.$root = this.$content.children('.mw-parser-output').first();
// 404 pages
if (!this.$root.length) {
this.$root = this.$content;
}
this.rootElement = this.$root[0];
}
// Add the class immediately to prevent the issue when any unexpected error prevents this from
// being executed and then `this.handleWikipageContentHookFirings()` is called with
// `#mw-content-text` element for some reason, and the page goes into an infinite reloading
// loop.
this.$root.addClass('cd-parse-started');
},
/**
* Reset the controller data and state. (Executed between page loads.)
*
* @private
*/
reset() {
this.cleanUpUrlAndDom();
this.mutationObserver?.disconnect();
commentRegistry.reset();
sectionRegistry.reset();
CommentForm.forgetOnTarget(cd.page, 'addSection');
this.$emulatedAddTopicButton?.remove();
delete this.$addTopicButtons;
this.content = {};
this.addedCommentCount = 0;
this.areRelevantCommentsAdded = false;
this.relevantAddedCommentIds = null;
delete this.dtSubscribableThreads;
this.updatePageTitle();
},
/**
* Set whether the current page is a talk page.
*
* @param {boolean} value
*/
setTalkPageness(value) {
this.talkPage = Boolean(value);
},
/**
* Check whether the current page is likely a talk page. See
* {@link module:controller.isDefinitelyTalkPage} for the most strict criteria.
*
* @returns {boolean}
*/
isTalkPage() {
return this.talkPage;
},
/**
* Check whether the current page is a watchlist or recent changes page.
*
* @returns {boolean}
*/
isWatchlistPage() {
return ['Recentchanges', 'Watchlist'].includes(mw.config.get('wgCanonicalSpecialPageName'));
},
/**
* Check whether the current page is a contributions page.
*
* @returns {boolean}
*/
isContributionsPage() {
return mw.config.get('wgCanonicalSpecialPageName') === 'Contributions';
},
/**
* Check whether the current page is a history page.
*
* @returns {boolean}
*/
isHistoryPage() {
return cd.g.pageAction === 'history' && isProbablyTalkPage(cd.g.pageName, cd.g.namespaceNumber);
},
/**
* Check whether the current page is a diff page.
*
* This is not a constant: the diff may be removed from the page (and the URL updated, see
* `.cleanUpUrlAndDom()`) when it's for the last revision and the page is reloaded using the
* script. `wgIsArticle` config value is not taken into account: if the "Do not show page content
* below diffs" MediaWiki setting is on, `wgIsArticle` is false.
*
* @returns {boolean}
*/
isDiffPage() {
return this.diffPage;
},
/**
* Check whether the current page meets strict criteria for classifying as a talk page. See
* {@link module:controller.isTalkPage} for approximate criteria.
*
* @returns {boolean}
*/
isDefinitelyTalkPage() {
return this.definitelyTalkPage;
},
/**
* Check if the _article_ page (the one with `wgIsArticle` being true) of the current page is a
* talk page eligible for CD. It can be `true` on edit, history pages etc. Although the
* assessments may be different on a history page and on an article page of the same title, since
* the page can contain elements with special classes that we can access only on the article page.
*
* @returns {boolean}
*/
isArticlePageTalkPage() {
return this.articlePageTalkPage;
},
/**
* Set whether the viewport is currently automatically scrolled to some position. To get that
* state, use {@link module:controller.isAutoScrolling}.
*
* @param {boolean} value
*/
toggleAutoScrolling(value) {
this.autoScrolling = Boolean(value);
},
/**
* Check whether the viewport is currently automatically scrolled to some position. To set that
* state, use {@link module:controller.toggleAutoScrolling}.
*
* @returns {boolean}
*/
isAutoScrolling() {
return this.autoScrolling;
},
/**
* Create an OOUI window manager or return an existing one.
*
* @param {string} [name='default'] Name of the window manager. We may need more than one if we,
* for some reason, want to have more than one window open at any moment.
* @returns {external:OO.ui.WindowManager}
*/
getWindowManager(name = 'default') {
this.windowManagers ||= {};
if (!this.windowManagers[name]) {
this.windowManagers[name] = new OO.ui.WindowManager();
this.windowManagers[name].on('closing', async (win, closed) => {
// We don't have windows that can be reused.
await closed;
this.windowManagers[name].clearWindows();
});
$(OO.ui.getTeleportTarget?.() || document.body).append(this.windowManagers[name].$element);
}
return this.windowManagers[name];
},
/**
* @class Api
* @memberof external:mw
* @see https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.Api
*/
/**
* Get a
* {@link https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.Api mw.Api} instance.
*
* @returns {external:mw.Api}
*/
getApi() {
this.api ||= new mw.Api(cd.getApiConfig());
return this.api;
},
/**
* _For internal use._ Get the worker object.
*
* @returns {Worker}
*/
getWorker() {
this.worker ||= new Worker();
return this.worker;
},
/**
* Get the offset data related to `.$contentColumn`.
*
* @param {boolean} reset Whether to bypass cache.
* @returns {object}
*/
getContentColumnOffsets(reset) {
if (!this.contentColumnOffsets || reset) {
const prop = cd.g.contentDirection === 'ltr' ? 'padding-left' : 'padding-right';
let startMargin = Math.max(parseFloat(this.$contentColumn.css(prop)), cd.g.contentFontSize);
// The content column in Timeless has no _borders_ as such, so it's wrong to penetrate the
// surrounding area from the design point of view.
if (cd.g.skin === 'timeless') {
startMargin--;
}
const left = this.$contentColumn.offset().left;
const width = this.$contentColumn.outerWidth();
this.contentColumnOffsets = {
startMargin,
start: cd.g.contentDirection === 'ltr' ? left : left + width,
end: cd.g.contentDirection === 'ltr' ? left + width : left,
};
// This is set only on window resize event. The initial value is set in
// `init.addTalkPageCss()` through a style tag.
if (reset) {
$(document.documentElement).css('--cd-content-start-margin', startMargin + 'px');
}
}
return this.contentColumnOffsets;
},
/**
* Is the displayed revision the current (last known) revision of the page.
*
* @returns {boolean}
*/
isCurrentRevision() {
// RevisionSlider may show a revision newer than the revision in `wgCurRevisionId` due to a bug
// (when navigating forward, at least twice, from a revision older than the revision in
// `wgCurRevisionId` after some revisions were added). Unfortunately, it doesn't update the
// `wgCurRevisionId` value.
return mw.config.get('wgRevisionId') >= mw.config.get('wgCurRevisionId');
},
/**
* Save the scroll position relative to the first element in the viewport looking from the top of
* the page.
*
* @param {?boolean} [switchToAbsolute=null] If this value is `true` or `false` and the viewport
* is above the bottom of the table of contents, then use
* {@link module:controller.saveScrollPosition} (this allows for better precision).
* @param {number} scrollY Cached horizontal scroll value used to avoid reflow.
*/
saveRelativeScrollPosition(switchToAbsolute = null, scrollY = window.scrollY) {
// The viewport has the TOC bottom or is above it.
if (
switchToAbsolute !== null &&
!toc.isInSidebar() &&
toc.isPresent() &&
scrollY < toc.getBottomOffset()
) {
this.saveScrollPosition(switchToAbsolute);
} else {
this.scrollData.element = null;
this.scrollData.elementTop = null;
this.scrollData.touchesBottom = false;
this.scrollData.offsetBottom = (
document.documentElement.scrollHeight - (scrollY + window.innerHeight)
);
// The number 100 accounts for various content moves by scripts running on the page (like
// HotCat that may add an empty category list).
if (this.scrollData.offsetBottom < 100) {
this.scrollData.touchesBottom = true;
} else if (
scrollY !== 0 &&
this.rootElement.getBoundingClientRect().top <= cd.g.bodyScrollPaddingTop
) {
const treeWalker = new ElementsTreeWalker(
this.rootElement.firstElementChild,
this.rootElement
);
while (true) {
const node = treeWalker.currentNode;
if (!isInline(node) && !this.getFloatingElements().includes(node)) {
const rect = node.getBoundingClientRect();
// By default, in a conversation between two people, replies are nested and there is no
// way to isolate the parent comment from the child, which would be desirable to find a
// good reference element. To work around this, we resort to this line, which stops the
// search at the first element fully below the viewport top (if there is a reference
// element already). Its shortcoming is that if 1) the only element we met with its
// bottom below the viewport top is too large to be used as a reference, 2) the first
// element small enough has its top below the viewport (i.e., there is a gap between it
// and the previous element that has the viewport top right in the middle) - we end up
// without a convenient reference element. To compensate for this, we use an offset of
// cd.g.contentFontSize (we're unlikely to see a bigger gap between elements).
if (
rect.top > cd.g.bodyScrollPaddingTop + cd.g.contentFontSize &&
this.scrollData.element &&
!isHeadingNode(node)
) {
break;
}
if (rect.height !== 0 && rect.bottom >= cd.g.bodyScrollPaddingTop) {
this.scrollData.element = node;
this.scrollData.elementTop = rect.top;
if (treeWalker.firstChild()) {
continue;
} else {
break;
}
}
}
if (!treeWalker.nextSibling()) break;
}
}
}
},
/**
* Restore the scroll position saved in {@link module:controller.saveRelativeScrollPosition}.
*
* @param {boolean} [switchToAbsolute=false] Restore the absolute position using
* {@link module:controller.restoreScrollPosition} if
* {@link module:controller.saveScrollPosition} was previously used for saving the position.
*/
restoreRelativeScrollPosition(switchToAbsolute = false) {
if (switchToAbsolute && this.scrollData.offset !== null) {
this.restoreScrollPosition();
} else {
if (this.scrollData.touchesBottom && window.scrollY !== 0) {
window.scrollTo(
0,
document.documentElement.scrollHeight - window.innerHeight - this.scrollData.offsetBottom
);
} else if (this.scrollData.element) {
const rect = this.scrollData.element.getBoundingClientRect();
if (getVisibilityByRects(rect)) {
window.scrollTo(0, window.scrollY + rect.top - this.scrollData.elementTop);
} else {
// In a collapsed thread?
const closestHidden = this.scrollData.element.closest('.cd-hidden');
if (closestHidden) {
commentRegistry.getAll()
.map((comment) => comment.thread)
.filter(defined)
.filter((thread) => thread.isCollapsed)
.find((thread) => thread.collapsedRange.includes(closestHidden))
?.$expandNote
.cdScrollTo('top', false);
}
}
}
}
},
/**
* _For internal use._ Replace the element used for restoring saved relative scroll position with
* a new element if it coincides with the provided element.
*
* @param {Element} element
* @param {Element} newElement
* @private
*/
replaceScrollAnchorElement(element, newElement) {
if (this.scrollData.element && element === this.scrollData.element) {
this.scrollData.element = newElement;
}
},
/**
* Save the scroll position to restore it later with
* {@link module:controller.restoreScrollPosition}.
*
* @param {boolean} [saveTocHeight=true] `false` is used for more fine control of scroll behavior
* when visits are loaded after a page reload.
*/
saveScrollPosition(saveTocHeight = true) {
this.scrollData.offset = window.scrollY;
this.scrollData.tocHeight = (
(saveTocHeight || this.scrollData.tocHeight) &&
!toc.isInSidebar() &&
toc.isPresent() &&
!toc.isFloating() &&
window.scrollY !== 0 &&
// There is some content below the TOC in the viewport.
toc.getBottomOffset() < window.scrollY + window.innerHeight
) ?
toc.$element.outerHeight() :
null;
},
/**
* Restore the scroll position saved in {@link module:controller.saveScrollPosition}.
*
* @param {boolean} [resetTocHeight=true] `false` is used for more fine control of scroll behavior
* after page reloads.
*/
restoreScrollPosition(resetTocHeight = true) {
if (this.scrollData.offset === null) return;
if (this.scrollData.tocHeight) {
this.scrollData.offset += (toc.$element.outerHeight() || 0) - this.scrollData.tocHeight;
}
window.scrollTo(0, this.scrollData.offset);
this.scrollData.offset = null;
if (resetTocHeight) {
this.scrollData.tocHeight = null;
}
},
/**
* Find closed discussions on the page.
*
* @returns {Element[]}
*/
getClosedDiscussions() {
if (!this.content.closedDiscussions) {
const closedDiscussionsSelector = cd.config.closedDiscussionClasses
.concat('mw-archivedtalk')
.map((name) => `.${name}`)
.join(', ');
this.content.closedDiscussions = this.$root.find(closedDiscussionsSelector).get();
}
return this.content.closedDiscussions;
},
/**
* Check whether there is at least one outdent template on the page. (If there is no, we don't
* need to run many expensive operations.)
*
* @returns {boolean}
*/
areThereOutdents() {
this.content.areThereOutdents ??= Boolean(this.$root.find('.' + cd.config.outdentClass).length);
return this.content.areThereOutdents;
},
/**
* Extract and memorize the classes mentioned in the TemplateStyles tags on the page.
*
* @private
*/
extractTemplateStylesSelectors() {
this.content.tsSelectorsFloating = [];
this.content.tsSelectorsHidden = [];
const extractSelectors = (rule) => {
if (rule instanceof CSSStyleRule) {
const style = rule.style;
if (style.float === 'left' || style.float === 'right') {
this.content.tsSelectorsFloating.push(rule.selectorText);
}
if (style.display === 'none') {
this.content.tsSelectorsHidden.push(rule.selectorText);
}
} else if (rule instanceof CSSMediaRule) {
[...rule.cssRules].forEach(extractSelectors);
}
};
[...document.styleSheets]
.filter((sheet) => sheet.href?.includes('site.styles'))
.forEach((el) => {
try {
[...el.cssRules].forEach(extractSelectors);
} catch {
// CSS rules on other domains can be inaccessible
}
});
[...this.rootElement.querySelectorAll('style')].forEach((el) => {
[...el.sheet.cssRules].forEach(extractSelectors);
});
},
/**
* Get the selectors for floating elements mentioned in the TemplateStyles tags on the page.
*
* @returns {string[]}
* @private
*/
getTsFloatingElementSelectors() {
if (!this.content.tsSelectorsFloating) {
this.extractTemplateStylesSelectors();
}
return this.content.tsSelectorsFloating;
},
/**
* Get the selectors for hidden elements mentioned in the TemplateStyles tags on the page.
*
* @returns {string[]}
* @private
*/
getTsHiddenElementSelectors() {
if (!this.content.tsSelectorsHidden) {
this.extractTemplateStylesSelectors();
}
return this.content.tsSelectorsHidden;
},
/**
* Find floating elements on the page.
*
* @returns {Element[]}
*/
getFloatingElements() {
if (!this.content.floatingElements) {
// Describe all floating elements on the page in order to calculate the correct border
// (temporarily setting `overflow: hidden`) for all comments they intersect with.
const floatingElementSelector = [
'.cd-floating',
'.tleft',
'.floatright',
'.floatleft',
'.mw-halign-right',
'.mw-halign-left',
'*[style*="float:right"]',
'*[style*="float: right"]',
'*[style*="float:left"]',
'*[style*="float: left"]',
'figure[typeof~="mw:File/Thumb"]',
'figure[typeof~="mw:File/Frame"]',
...this.getTsFloatingElementSelectors(),
].join(', ');
// Can't use jQuery here anyway, as .find() doesn't take into account ancestor elements, such
// as .mw-parser-output, in selectors. Remove all known elements that never intersect comments
// from the collection.
this.content.floatingElements = [
...this.rootElement.querySelectorAll(floatingElementSelector)
].filter((el) => !el.classList.contains('cd-ignoreFloating'));
}
return this.content.floatingElements;
},
/**
* Find floating and hidden (`display: none`) elements on the page.
*
* @returns {Element[]}
*/
getHiddenElements() {
if (!this.hiddenElements) {
const hiddenElementSelector = this.getTsHiddenElementSelectors().join(', ');
this.hiddenElements = hiddenElementSelector ?
[...this.rootElement.querySelectorAll(hiddenElementSelector)] :
[];
}
return this.hiddenElements;
},
/**
* Check whether there is "LTR inside RTL" or "RTL inside LTR" nesting on the page.
*
* @returns {boolean}
*/
areThereLtrRtlMixes() {
this.content.areThereLtrRtlMixes ??= Boolean(
document.querySelector('.sitedir-ltr .mw-content-rtl, .sitedir-rtl .mw-content-ltr')
);
return this.content.areThereLtrRtlMixes;
},
/**
* Get the popup overlay used for OOUI components.
*
* @returns {external:jQuery}
*/
getPopupOverlay() {
this.$popupOverlay ??= $('<div>')
.addClass('cd-popupOverlay')
.appendTo(document.body);
return this.$popupOverlay;
},
/**
* Add a condition preventing page unload.
*
* @param {string} name
* @param {Function} condition
*/
addPreventUnloadCondition(name, condition) {
this.beforeUnloadHandlers ||= {};
this.beforeUnloadHandlers[name] = (e) => {
if (!condition()) return;
e.preventDefault();
e.returnValue = '';
return '';
};
$(window).on('beforeunload', this.beforeUnloadHandlers[name]);
},
/**
* Remove a condition preventing page unload.
*
* @param {string} name
*/
removePreventUnloadCondition(name) {
if (!this.beforeUnloadHandlers[name]) return;
$(window).off('beforeunload', this.beforeUnloadHandlers[name]);
delete this.beforeUnloadHandlers[name];
},
/**
* _For internal use._ Handle a mouse move event (including `mousemove` and `mouseover`).
*
* @param {Event} e
*/
handleMouseMove(e) {
if (this.mouseMoveBlocked || this.isAutoScrolling() || this.isPageOverlayOn()) return;
// Don't throttle. Without throttling, performance is generally OK, while the "frame rate" is
// about 50 (so, the reaction time is about 20ms). Lower values would be less comfortable.
this.emit('mouseMove', e);
},
/**
* _For internal use._ Are there elements obstructing the content area, like popups or windows.
*
* @returns {boolean}
*/
isObstructingElementHovered() {
if (this.notificationArea === undefined) {
this.notificationArea = $('.mw-notification-area')[0];
this.tocButton = $('#vector-page-titlebar-toc')[0];
this.stickyHeader = $('#vector-sticky-header')[0];
this.tocContent = $('.vector-dropdown-content')[0];
}
OO.ui.throttle(() => {
// We just list everything we know that can stand between the user and the content area where
// comments reside. This is a very ugly method I resorted to because I honestly don't know the
// alternatives. We can't put any element out there to check `:hover` on it because the
// absence of such an element for comments is the reason why we need to check for obstructing
// elements in the first place. On the other hand, if this incorrectly returns `false`, this
// doesn't really affect anything important. It's just for better visual effects. Use vanilla
// JS where possible.
this.isObstructingElementHoveredCached = Boolean(
[
...(this.notificationArea?.querySelectorAll('.mw-notification') || []),
Autocomplete.getActiveMenu(),
navPanel.$element?.[0],
...document.body.querySelectorAll('.oo-ui-popupWidget:not(.oo-ui-element-hidden)'),
$(document.body).children('dialog')[0],
this.stickyHeader,
sectionRegistry.getAll()
.map((section) => section.actions.moreMenuSelect?.getMenu())
.find((menu) => menu?.isVisible())
?.$element[0],
this.tocButton,
this.tocContent,
]
.filter(definedAndNotNull)
.some((el) => el.matches(':hover')) ||
// WikiEditor dialog
$(document.body).children('.ui-dialog').not('[style*="display: none"]').length
);
}, 100)();
return this.isObstructingElementHoveredCached;
},
/**
* Handles the window `resize` event as well as `orientationchange`.
*
* @private
*/
async handleWindowResize() {
// `sleep()`, because it seems like sometimes it doesn't have time to update.
await sleep(cd.g.skin === 'vector-2022' ? 100 : 0);
this.getContentColumnOffsets(true);
this.emit('resize');
this.handleScroll();
},
/**
* Handles `keydown` event on the document.
*
* @param {Event} e
* @private
*/
handleGlobalKeyDown(e) {
if (this.isPageOverlayOn()) return;
this.emit('keydown', e);
},
/**
* _For internal use._ Handle a document's `scroll` event: Register seen comments, update the
* navigation panel's first unseen button, and update the current section block. Trigger the
* `horizontalscroll` event.
*/
handleScroll() {
// Scroll will be handled when the autoscroll is finished.
if (this.isAutoScrolling()) return;
this.mouseMoveBlocked = true;
// Throttle handling scroll to run not more than once in 300ms. Wait before running, otherwise
// comments may be registered as seen after a press of Page Down/Page Up. One scroll in Chrome,
// Firefox with Page Up/Page Down takes a little less than 200ms, but 200ms proved to be not
// enough, so we try 300ms.
this.throttledHandleScroll ||= OO.ui.throttle(() => {
this.mouseMoveBlocked = false;
if (this.isAutoScrolling()) return;
this.emit('scroll');
}, 300);
this.throttledHandleScroll();
if (window.scrollX !== this.lastScrollX) {
$(document).trigger('horizontalscroll.cd');
}
this.lastScrollX = window.scrollX;
},
/**
* Handle a `horizontalscroll` event, triggered from {@link module:controller.handleScroll}.
*
* @private
*/
handleHorizontalScroll() {
this.emit('horizontalScroll');
},
/**
* Handle a `popstate` event, including clicks on links pointing to comment anchors.
*
* @private
*/
handlePopState() {
// Use `popstate`, not `hashchange`, because we need to handle cases when the user clicks a
// link with the same fragment as is in the URL.
try {
this.emit('popState', decodeURIComponent(location.hash.slice(1)));
} catch (e) {
console.error(e);
}
// Make sure the title has no incorrect new comment count when the user presses the "Back"
// button after an (internal) page reload.
this.updatePageTitle();
},
/**
* Handle a `selectionchange` event.
*
* @private
*/
handleSelectionChange() {
this.throttledHandleSelectionChange ||= OO.ui.throttle(() => {
this.emit('selectionChange');
}, 200);
this.throttledHandleSelectionChange();
},
/**
* Handle page (content area) mutations.
*
* @private
*/
handlePageMutate() {
if (this.booting) return;
this.emit('mutate');
// Could also run `this.handleScroll()` here, but not sure, as it would double the execution
// time with rare effect.
},
/**
* Handle a click on an "Add topic" button excluding those added by the script.
*
* @param {Event} e
* @private
*/
handleAddTopicButtonClick(e) {
if (e.ctrlKey || e.shiftKey || e.metaKey) return;
const $button = $(e.currentTarget);
let preloadConfig;
let newTopicOnTop = false;
if ($button.is('a')) {
const { searchParams } = new URL($button.prop('href'));
preloadConfig = {
editIntro: getLastArrayElementOrSelf(searchParams.getAll('editintro')),
commentTemplate: getLastArrayElementOrSelf(searchParams.getAll('preload')),
headline: getLastArrayElementOrSelf(searchParams.getAll('preloadtitle')),
params: searchParams.getAll('preloadparams[]'),
summary: getLastArrayElementOrSelf(searchParams.getAll('summary'))?.replace(/^.+?\*\/ */, ''),
noHeadline: Boolean(getLastArrayElementOrSelf(searchParams.getAll('nosummary'))),
omitSignature: Boolean(searchParams.get('cdomitsignature')),
};
newTopicOnTop = getLastArrayElementOrSelf(searchParams.getAll('section')) === '0';
} else {
// <input>
const $form = $button.closest('form');
preloadConfig = {
editIntro: $form.find('input[name="editintro"]').val(),
commentTemplate: $form.find('input[name="preload"]').val(),
headline: $form.find('input[name="preloadtitle"]').val(),
params: $form.find('input[name="preloadparams[]"]').get().map((el) => el.value),
summary: $form.find('input[name="summary"]').val(),
noHeadline: Boolean($form.find('input[name="nosummary"]').val()),
omitSignature: false,
};
}
e.preventDefault();
cd.page.addSection(undefined, undefined, preloadConfig, newTopicOnTop);
},
/**
* Is the page loading (the loading overlay is on).
*
* @returns {boolean}
*/
isBooting() {
return this.booting;
},
/**
* Is there any kind of a page overlay present, like the OOUI modal overlay or CD loading overlay.
* This runs very frequently.
*
* @returns {boolean}
*/
isPageOverlayOn() {
return document.body.classList.contains('oo-ui-windowManager-modal-active') || this.booting;
},
/**
* Show the loading overlay (a logo in the corner of the page).
*
* @private
*/
showLoadingOverlay() {
if (window.cdShowLoadingOverlay === false) return;
if (!this.$loadingPopup) {
this.$loadingPopup = $('<div>')
.addClass('cd-loadingPopup')
.append(
$('<div>')
.addClass('cd-loadingPopup-logo')
.append(
$('<div>').addClass('cd-loadingPopup-logo-partBackground'),
$('<img>').attr('src', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADcAAAA3CAYAAACo29JGAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAewQAAHsEBw2lUUwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAK7SURBVGiB3Zq/axRBFMc/60VioQgW1yjEiwa0tdXiCkH9AwLaKCLY+Aek9CxUbGw9/wMbrYQgCIrEpEgUAimNmCAqapWAGL2wFrPr7R374739kZ3ZL7ziuHlv3mdndufN7MJQHaAPbAIDwK/ZBkEufeA4BXQB2LIAKMm2ghzV6lgOFgXsaOEeW5C41PpauE0LkpbahgbMw9y4LY1TjdoFJqSNPcwVcUmetOE+ZeA/wAqwhBnxvPoBvAY+FoghknS+vwNORPymgVWFf2h3gf1BDA+4Buwo/EuH+x3AjGsG+KtI7HlCDvfqhFtK8V9RJHY9IcaZKuCk99xOyn+aDtPiaNVlCJxYqkmn5bGYDk6iq0OfJSR6XxEjDi5qI6WaNOgyMBUJnveB0mN0rbqK7r7NggsBOxq4cAQXgQWK7Ry+Ai+BDzl8JXA+QamWN8G6TAq3oV3EXdLRJsO1pEXoe2C9ykyAi8ChsoNK5vmLsjsd02lMxV/mPecjDOgDZ6tj46kij1BdSVtp0E/AkQrAbipyqAzOB9YYXciL6gZmG2UFnA/8BG4x3Lbk0TS6qbhncKF9Ax4Cl4DDGTAecAozUvMUq27EcGUeM3wHvmBG1g+AJoE2ZiofKKmf8JihC7xKayg+bBGoHZg1cq1C2dU0dg3us6axa3DzmsYuwW0DDyK/J7McXIHbBmYxVVKoGYlj3vWmahtg3g08Iv793BtBDHFnPcmV2iNdQbjguwj2C0HekkX8DkO482VnKtQE5ij/MnBO45hGf1vR1kYTgzUGrhcDBnZ85VAILgkMzKO57oRzw6WBgTnFrTvhXHBZYGAWUxc+6xiBk4CFsv2DnP/WwuxsNXDrwBPMzroNHMSdGtV6zaGYli5KCuisJIBOKwvQeaUBNkJJgI1RHGCjNA7YOEUBG6k5gvKriXoLeP8AAFe0oEsY7eMAAAAASUVORK5CYII='),
)
);
$(document.body).append(this.$loadingPopup);
} else {
this.$loadingPopup.show();
}
},
/**
* Hide the loading overlay.
*
* @private
*/
hideLoadingOverlay() {
if (!this.$loadingPopup || window.cdShowLoadingOverlay === false) return;
this.$loadingPopup.hide();
},
/**
* Run the {@link BootProcess boot process} and catch errors.
*
* @param {boolean} isReload Is the page reloaded.
* @private
*/
async tryExecuteBootProcess(isReload) {
this.booting = true;
// We could say "let it crash", but, well, unforeseen errors in `BootProcess#execute()` are just
// too likely to go without a safeguard.
try {
await this.bootProcess.execute(isReload);
if (isReload) {
mw.hook('wikipage.content').fire(this.$content);
}
} catch (e) {
mw.notify(cd.s('error-processpage'), { type: 'error' });
console.error(e);
this.hideLoadingOverlay();
}
this.booting = false;
},
/**
* Get the current (or last available) boot process.
*
* @returns {?BootProcess}
*/
getBootProcess() {
return this.bootProcess || null;
},
/**
* Load the data required for the script to run on a talk page and execute the
* {@link BootProcess boot process}.
*
* @private
*/
bootOnTalkPage() {
if (!this.talkPage) return;
debug.stopTimer('start');
debug.startTimer('load data');
/**
* Last boot process.
*
* @type {BootProcess|undefined}
* @private
*/
this.bootProcess = new BootProcess();
let siteDataRequests = [];
// Make some requests in advance if the API module is ready in order not to make 2 requests
// sequentially. We don't make a `userinfo` request, because if there is more than one tab in
// the background, this request is made and the execution stops at mw.loader.using, which
// results in overriding the renewed visits setting of one tab by another tab (the visits are
// loaded by one tab, then another tab, then written by one tab, then by another tab).
if (mw.loader.getState('mediawiki.api') === 'ready') {
siteDataRequests = init.getSiteData();
// We are _not_ calling `getUserInfo()` here to avoid losing visit data updates from some
// pages if several pages are opened simultaneously. In this situation, visits could be
// requested for multiple pages; updated and then saved for each of them with losing the
// updates from the rest.
}
const modules = [
'jquery.client',
'jquery.ui',
'mediawiki.Title',
'mediawiki.Uri',
'mediawiki.api',
'mediawiki.cookie',
'mediawiki.interface.helpers.styles',
'mediawiki.jqueryMsg',
'mediawiki.notification',
'mediawiki.storage',
'mediawiki.user',
'mediawiki.util',
'mediawiki.widgets.visibleLengthLimit',
'oojs',
'oojs-ui-core',
'oojs-ui-widgets',
'oojs-ui-windows',
'oojs-ui.styles.icons-alerts',
'oojs-ui.styles.icons-content',
'oojs-ui.styles.icons-editing-advanced',
'oojs-ui.styles.icons-editing-citation',
'oojs-ui.styles.icons-editing-core',
'oojs-ui.styles.icons-interactions',
'oojs-ui.styles.icons-movement',
'user.options',
mw.loader.getState('ext.confirmEdit.CaptchaInputWidget') ?
'ext.confirmEdit.CaptchaInputWidget' :
undefined,
].filter(defined);
// `mw.loader.using` delays the execution even if all modules are ready (if CD is used as a
// gadget with preloaded dependencies, for example), so we use this trick.
let modulesRequest;
if (modules.every((module) => mw.loader.getState(module) === 'ready')) {
// If there is no data to load and, therefore, no period of time within which a reflow (layout
// thrashing) could happen without impeding performance, we cache the value so that it could
// be used in `.saveRelativeScrollPosition()` without causing a reflow.
if (siteDataRequests.every((request) => request.state() === 'resolved')) {
this.bootProcess.passedData = { scrollY: window.scrollY };
}
} else {
modulesRequest = mw.loader.using(modules);
}
this.showLoadingOverlay();
Promise.all([modulesRequest, ...siteDataRequests]).then(
async () => {
// Do it here because `OO.EventEmitter` can be unavailable before.
mixEventEmitterIntoObject(this);
await this.tryExecuteBootProcess();
updateChecker
.on('check', (revisionId) => {
this.lastCheckedRevisionId = revisionId;
})
.on('commentsUpdate', this.updateAddedComments.bind(this));
},
(e) => {
mw.notify(cd.s('error-loaddata'), { type: 'error' });
console.error(e);
this.hideLoadingOverlay();
}
);
// https://phabricator.wikimedia.org/T68598 "mw.loader state of module stuck at "loading" if
// request was aborted"
sleep(15000).then(() => {
if (this.booting) {
this.hideLoadingOverlay();
console.warn('The loading overlay stays for more than 15 seconds; removing it.');
}
});
this.$contentColumn = skin$({
timeless: '#mw-content',
minerva: '#bodyContent',
default: '#content',
});
/*
Additions of CSS set a stage for a future reflow which delays operations dependent on
rendering, so we run them now, not after the requests are fulfilled, to save time. The overall
order is like this:
1. Make network requests (above).
2. Run operations dependent on rendering, such as window.getComputedStyle() and jQuery's
.css() (below). Normally they would initiate a reflow, but, as we haven't changed the
layout or added CSS yet, there is nothing to update.
3. Run operations that create prerequisites for a reflow, such as adding CSS. Thanks to the
fact that the network requests, if any, are already pending, we don't lose time.
*/
init.memorizeCssValues();
init.addTalkPageCss();
},
/**
* Reload the page via Ajax.
*
* @param {import('./BootProcess').PassedData} [passedData={}] Data passed from the previous page
* state. See {@link PassedData} for the list of possible properties. `html`, `unseenCommentIds`
* properties are set in this function.
* @throws {import('./CdError').default|Error}
*/
async reload(passedData = {}) {
if (this.booting) return;
passedData.isRevisionSliderRunning = Boolean(history.state?.sliderPos);
const bootProcess = new BootProcess(passedData);
// We reset the live timestamps only during the boot process, because we shouldn't dismount the
// components of the current version of the page at least until a correct response to the parse
// request is received. Otherwise, if the request fails, the user would be left with a
// dysfunctional page.
this.emit('beforeReload', bootProcess.passedData);
if (!bootProcess.passedData.commentIds && !bootProcess.passedData.sectionId) {
this.saveScrollPosition();
}
debug.init();
debug.startTimer('total time');
debug.startTimer('get HTML');
this.showLoadingOverlay();
// Save time by requesting the options in advance. This also resets the cache since the `reuse`
// argument is `false`.
getUserInfo().catch((e) => {
console.warn(e);
});
try {
bootProcess.passedData.parseData = await cd.page.parse(null, false, true);
} catch (e) {
this.hideLoadingOverlay();
if (bootProcess.passedData.wasCommentFormSubmitted) {
throw e;
} else {
mw.notify(cd.s('error-reloadpage'), { type: 'error' });
console.warn(e);
return;
}
}
// Get IDs of unseen comments. This is used to arrange that they will still be there after
// replying on or refreshing the page.
bootProcess.passedData.unseenCommentIds = commentRegistry
.query((comment) => comment.isSeen === false)
.map((comment) => comment.id);
// At this point, the boot process can't be interrupted, so we can remove all traces of the
// current page state.
this.bootProcess = bootProcess;
this.emit('startReload');
// Just submitted "Add section" form (it is outside of the `.$root` element). Forms that should
// stay are detached above.
this.$addSectionButtonContainer?.remove();
$('.cd-commentForm-addSection').remove();
this.reset();
debug.stopTimer('get HTML');
await this.tryExecuteBootProcess(true);
this.emit('reload');
if (!bootProcess.passedData.commentIds && !bootProcess.passedData.sectionId) {
this.restoreScrollPosition(false);
}
},
/**
* _For internal use._ Update the page's HTML and certain configuration values.
*
* @param {object} parseData
*/
updatePageContents(parseData) {
this.$content.children('.mw-parser-output').first().replaceWith(this.$root);
mw.util.clearSubtitle?.();
mw.util.addSubtitle?.(parseData.subtitle);
if ($('#catlinks').length) {
const $categories = $($.parseHTML(parseData.categorieshtml));
mw.hook('wikipage.categories').fire($categories);
$('#catlinks').replaceWith($categories);
}
mw.config.set({
wgRevisionId: parseData.revid,
wgCurRevisionId: parseData.revid,
});
mw.loader.load(parseData.modules);
mw.loader.load(parseData.modulestyles);
mw.config.set(parseData.jsconfigvars);
},
/**
* Highlight mentions of the current user.
*
* @param {external:jQuery} $content
* @private
*/
highlightMentions($content) {
if (!$content.is('#mw-content-text, .cd-comment-part')) return;
const currentUserName = cd.user.getName();
const excludeSelector = [
settings.get('reformatComments') ?
'cd-comment-author' :
'cd-signature'
]
.concat(cd.config.noSignatureClasses)
.map((name) => `.${name}`)
.join(', ');
$content
.find(
$content.hasClass('cd-comment-part') ?
`a[title$=":${currentUserName}"], a[title*=":${currentUserName} ("]` :
`.cd-comment-part a[title$=":${currentUserName}"], .cd-comment-part a[title*=":${currentUserName} ("]`
)
.filter(function () {
return (
cd.g.userLinkRegexp.test(this.title) &&
!this.closest(excludeSelector) &&
Parser.processLink(this)?.userName === cd.user.getName()
);
})
.each((i, link) => {
link.classList.add('cd-currentUserLink');
});
},
/**
* Add event listeners to `window`, `document`, hooks.
*
* @private
*/
addEventListeners() {
if (!settings.get('reformatComments')) {
// The `mouseover` event allows to capture the state when the cursor is not moving but ends up
// above a comment but not above any comment parts (for example, as a result of scrolling).
// The benefit may be low compared to the performance cost, but it's unexpected when the user
// scrolls a comment and it suddenly stops being highlighted because the cursor is between
// neighboring `<p>`'s.
$(document).on('mousemove mouseover', this.handleMouseMove.bind(this));
}
// We need the `visibilitychange` event because many things may move while the document is
// hidden, and movements are not processed when the document is hidden.
$(document)
.on('scroll visibilitychange', this.handleScroll.bind(this))
.on('horizontalscroll.cd visibilitychange', this.handleHorizontalScroll.bind(this))
.on('selectionchange', this.handleSelectionChange.bind(this));
$(window)
.on('resize orientationchange', this.handleWindowResize.bind(this))
.on('popstate', this.handlePopState.bind(this));
// Should be above `mw.hook('wikipage.content').fire` so that it runs for the whole page content
// as opposed to `$('.cd-comment-author-wrapper')`.
mw.hook('wikipage.content').add(
this.connectToCommentLinks.bind(this),
this.highlightMentions.bind(this)
);
mw.hook('convenientDiscussions.previewReady').add(this.connectToCommentLinks.bind(this));
// Mutation observer doesn't follow all possible comment position changes (for example,
// initiated with adding new CSS) unfortunately.
setInterval(this.handlePageMutate.bind(this), 1000);
if (cd.page.isCommentable()) {
$(document).on('keydown', this.handleGlobalKeyDown.bind(this));
}
mw.hook('wikipage.content').add(this.handleWikipageContentHookFirings.bind(this));
},
/**
* Bind a click handler to comment links to make them work as in-script comment links.
*
* This method exists in addition to {@link module:controller.handlePopState}. It's preferable to
* have click events handled by this method instead of `.handlePopState()` because that method, if
* encounters `cdJumpedToComment` in the history state, doesn't scroll to the comment which is a
* wrong behavior when the user clicks a link.
*
* @param {external:jQuery} $content
* @private
*/
connectToCommentLinks($content) {
if (!$content.is('#mw-content-text, .cd-commentForm-preview')) return;
$content
.find(`a[href^="#"]`)
.filter(function () {
return !this.onclick && Comment.isAnyId($(this).attr('href').slice(1));
})
.on('click', function (e) {
e.preventDefault();
commentRegistry.getByAnyId($(this).attr('href').slice(1), true)?.scrollTo({
expandThreads: true,
pushState: true,
});
});
},
/**
* Handle firings of the hook
* {@link https://doc.wikimedia.org/mediawiki-core/master/js/Hooks.html#~event:'wikipage.content' wikipage.content}
* (by using `mw.hook('wikipage.content').fire()`). This is performed by some user scripts, such
* as QuickEdit.
*
* @param {external:jQuery} $content
* @private
*/
handleWikipageContentHookFirings($content) {
if (!$content.is('#mw-content-text')) return;
const $root = $content.children('.mw-parser-output');
if ($root.length && !$root.hasClass('cd-parse-started')) {
this.reload({ isPageReloadedExternally: true });
}
},
/**
* Remove diff-related DOM elements.
*
* @param {URLSearchParams} searchParams
* @private
*/
cleanUpDom(searchParams) {
if (!searchParams.has('diff') && !searchParams.has('oldid')) return;
// Diff pages
this.$content
.children('.mw-revslider-container, .mw-diff-table-prefix, .diff, .oo-ui-element-hidden, .diff-hr, .diff-currentversion-title')
.remove();
// Revision navigation
$('.mw-revision').remove();
$('#firstHeading').text(cd.page.name);
document.title = cd.mws('pagetitle', cd.page.name);
this.originalPageTitle = document.title;
},
/**
* Remove fragment and revision parameters from the URL.
*
* @param {URLSearchParams} searchParams
* @private
*/
cleanUpUrl(searchParams) {
const newQuery = Object.fromEntries(searchParams.entries());
// `title` will be added automatically (after /wiki/ if possible, as a query parameter
// otherwise).
delete newQuery.title;
delete newQuery.curid;
delete newQuery.action;
delete newQuery.redlink;
delete newQuery.section;
delete newQuery.cdaddtopic;
delete newQuery.dtnewcommentssince;
delete newQuery.dtinthread;
let methodName;
if (newQuery.diff || newQuery.oldid) {
methodName = 'pushState';
delete newQuery.diff;
delete newQuery.oldid;
delete newQuery.diffmode;
delete newQuery.type;
// Make the "Back" browser button work.
$(window).on('popstate', () => {
const { searchParams } = new URL(location.href);
if (searchParams.has('diff') || searchParams.has('oldid')) {
location.reload();
}
});
this.diffPage = false;
} else if (!this.bootProcess.passedData.pushState) {
// Don't reset the fragment if it will be set in the boot process from a comment ID or a
// section ID, to avoid creating an extra history entry.
methodName = 'replaceState';
}
if (methodName) {
history[methodName](history.state, '', cd.page.getUrl(newQuery));
}
},
/**
* Remove fragment and revision parameters from the URL; remove DOM elements related to the diff.
*/
cleanUpUrlAndDom() {
if (this.bootProcess.passedData.isRevisionSliderRunning) return;
const { searchParams } = new URL(location.href);
this.cleanUpDom(searchParams);
this.cleanUpUrl(searchParams);
},
/**
* Load the data required for the script to process the page as a log page and
* {@link module:addCommentLinks process it}.
*
* @private
*/
bootOnCommentLinksPage() {
if (
!this.isWatchlistPage() &&
!this.isContributionsPage() &&
!this.isHistoryPage() &&
!(this.diffPage && this.articlePageTalkPage) &&
// Instant Diffs script can be called on talk pages as well
!this.talkPage
) {
return;
}
// Make some requests in advance if the API module is ready in order not to make 2 requests
// sequentially.
if (mw.loader.getState('mediawiki.api') === 'ready') {
init.getSiteData();
// Loading user info on diff pages could lead to problems with saving visits when many pages
// are opened, but not yet focused, simultaneously.
if (!this.talkPage) {
getUserInfo(true).catch((e) => {
console.warn(e);
});
}
}
mw.loader.using([
'jquery.client',
'mediawiki.Title',
'mediawiki.api',
'mediawiki.jqueryMsg',
'mediawiki.user',
'mediawiki.util',
'oojs',
'oojs-ui-core',
'oojs-ui-widgets',
'oojs-ui-windows',
'oojs-ui.styles.icons-alerts',
'oojs-ui.styles.icons-editing-list',
'oojs-ui.styles.icons-interactions',
'user.options',
]).then(
() => {
addCommentLinks();
// See the comment above: "Additions of CSS...".
require('./global.less');
require('./logPages.less');
},
(e) => {
mw.notify(cd.s('error-loaddata'), { type: 'error' });
console.error(e);
}
);
},
/**
* _For internal use._ Check whether the page qualifies to be considered a long page (which
* affects attempting performance improvements).
*
* @returns {boolean}
*/
isLongPage() {
this.content.longPage ??= $(document).height() > 15000;
return this.content.longPage;
},
/**
* Get the content root element (`.mw-parser-output` or `#mw-content-text`). Supposed to be used
* via {@link convenientDiscussions.api.getRootElement}; inside the script, direct reference to
* `controller.rootElement` is practiced.
*
* @returns {Element}
*/
getRootElement() {
return this.rootElement;
},
/**
* Show an edit subscriptions dialog.
*/
showEditSubscriptionsDialog() {
if (this.isPageOverlayOn()) return;
const dialog = new (require('./EditSubscriptionsDialog').default)();
this.getWindowManager().addWindows([dialog]);
this.getWindowManager().openWindow(dialog);
},
/**
* Show a copy link dialog.
*
* @param {Comment|import('./Section').default} object Comment or section to copy a link to.
* @param {Event} e
*/
showCopyLinkDialog(object, e) {
if (this.isPageOverlayOn()) return;
e.preventDefault();
const fragment = object.getWikilinkFragment();
const permalinkSpecialPageName = (
mw.config.get('wgFormattedNamespaces')[-1] +
':' +
cd.g.specialPageAliases.PermanentLink[0] +
'/' +
mw.config.get('wgRevisionId')
);
const content = {
fragment,
wikilink: `[[${cd.page.name}#${fragment}]]`,
currentPageWikilink: `[[#${fragment}]]`,
permanentWikilink: `[[${permalinkSpecialPageName}#${fragment}]]`,
link: object.getUrl(),
permanentLink: object.getUrl(true),
copyMessages: {
success: cd.s('copylink-copied'),
fail: cd.s('copylink-error'),
},
};
// Undocumented feature allowing to copy a link of a default type without opening a dialog.
const relevantSetting = object instanceof Comment ?
settings.get('defaultCommentLinkType') :
settings.get('defaultSectionLinkType');
if (!e.shiftKey && relevantSetting) {
switch (relevantSetting) {
case 'wikilink':
copyText(content.wikilink, content.copyMessages);
break;
case 'link':
copyText(content.link, content.copyMessages);
break;
}
return;
}
const dialog = new (require('./CopyLinkDialog').default)(object, content);
this.getWindowManager().addWindows([dialog]);
this.getWindowManager().openWindow(dialog);
},
/**
* Scroll to a specified position vertically.
*
* @param {number} y
* @param {boolean} [smooth=true]
* @param {Function} [callback]
*/
scrollToY(y, smooth = true, callback) {
const onComplete = () => {
this.toggleAutoScrolling(false);
this.handleScroll();
callback?.();
};
if (smooth) {
$('body, html').animate({ scrollTop: y }, {
complete: function () {
if (this !== document.documentElement) return;
onComplete();
},
});
} else {
window.scrollTo(window.scrollX, y);
onComplete();
}
},
/**
* Set up a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver MutationObserver}
* instance to handle page mutations.
*/
async setupMutationObserver() {
// Create the mutation observer in the next event cycle - let most DOM changes by CD and scripts
// attached to the hooks to be made first to reduce the number of times it runs in vain. But if
// we set a long delay, users will see comment backgrounds mispositioned for some time.
await sleep();
this.mutationObserver = new MutationObserver((records) => {
const layerClassRegexp = /^cd-comment(-underlay|-overlay|Layers)/;
if (records.every((record) => layerClassRegexp.test(record.target.className))) return;
this.handlePageMutate();
});
this.mutationObserver.observe(this.$content[0], {
attributes: true,
childList: true,
subtree: true,
});
},
/**
* Show a regular notification (`mw.notification`) to the user.
*
* @param {import('./CommentSkeleton').CommentSkeletonLike[]} comments
* @private
*/
showRegularNotification(comments) {
let filteredComments = [];
if (settings.get('notifications') === 'all') {
filteredComments = comments;
} else if (settings.get('notifications') === 'toMe') {
filteredComments = comments.filter((comment) => comment.isToMe);
}
if (settings.get('notifications') !== 'none' && filteredComments.length) {
// Combine with content of notifications that were displayed but are still open (i.e., the
// user most likely didn't see them because the tab is in the background). In the past there
// could be more than one notification, now there can be only one.
const openNotification = notifications.get()
.find((data) => data.comments && data.notification.isOpen);
if (openNotification) {
filteredComments.push(...openNotification.comments);
}
}
if (filteredComments.length) {
let html;
const reloadHtml = cd.sParse(
'notification-reload',
// Note about the form data
commentFormRegistry.getAll().some((cf) => cf.isAltered()) ?
' ' + cd.mws('parentheses', cd.s('notification-formdata')) :
''
);
if (filteredComments.length === 1) {
const comment = filteredComments[0];
if (comment.isToMe) {
const where = comment.sectionSubscribedTo ?
(
cd.mws('word-separator') +
cd.s('notification-part-insection', comment.sectionSubscribedTo.headline)
) :
cd.mws('word-separator') + cd.s('notification-part-onthispage');
html = (
cd.sParse('notification-toyou', comment.author.getName(), comment.author, where) +
' ' +
reloadHtml
);
} else {
html = (
cd.sParse(
'notification-insection',
comment.author.getName(),
comment.author,
comment.sectionSubscribedTo.headline
) +
' ' +
reloadHtml
);
}
} else {
const isCommonSection = filteredComments.every((comment) => (
comment.sectionSubscribedTo === filteredComments[0].sectionSubscribedTo
));
const section = isCommonSection ? filteredComments[0].sectionSubscribedTo : undefined;
const where = (
cd.mws('word-separator') +
(
section ?
cd.s('notification-part-insection', section.headline) :
cd.s('notification-part-onthispage')
)
);
let mayBeRelevantString = cd.s('notification-newcomments-mayberelevant');
if (!mayBeRelevantString.startsWith(cd.mws('comma-separator'))) {
mayBeRelevantString = cd.mws('word-separator') + mayBeRelevantString;
}
// "that may be relevant to you" text is not needed when the section is watched and the user
// can clearly understand why they are notified.
const mayBeRelevant = section ? '' : mayBeRelevantString;
html = (
cd.sParse('notification-newcomments', filteredComments.length, where, mayBeRelevant) +
' ' +
reloadHtml
);
}
const notification = notifications.add(
wrapHtml(html),
{ tag: 'convenient-discussions-new-comments' },
{ comments: filteredComments }
);
notification.$notification.on('click', () => {
this.reload({ commentIds: filteredComments.map((comment) => comment.id) });
});
}
},
/**
* Show a desktop notification to the user.
*
* @param {import('./CommentSkeleton').CommentSkeletonLike[]} comments
* @private
*/
showDesktopNotification(comments) {
let filteredComments = [];
if (settings.get('desktopNotifications') === 'all') {
filteredComments = comments;
} else if (settings.get('desktopNotifications') === 'toMe') {
filteredComments = comments.filter((comment) => comment.isToMe);
}
if (
typeof Notification === 'undefined' ||
Notification.permission !== 'granted' ||
!filteredComments.length ||
document.hasFocus()
) {
return;
}
let body;
const comment = filteredComments[0];
const currentPageName = cd.page.name;
if (filteredComments.length === 1) {
if (comment.isToMe) {
const where = comment.section?.headline ?
cd.mws('word-separator') + cd.s('notification-part-insection', comment.section.headline) :
'';
body = cd.s(
'notification-toyou-desktop',
comment.author.getName(),
comment.author,
where,
currentPageName
);
} else {
body = cd.s(
'notification-insection-desktop',
comment.author.getName(),
comment.author,
comment.section.headline,
currentPageName
);
}
} else {
let section;
const isCommonSection = filteredComments.every((comment) => (
comment.sectionSubscribedTo === filteredComments[0].sectionSubscribedTo
));
if (isCommonSection) {
section = filteredComments[0].sectionSubscribedTo;
}
const where = section ?
cd.mws('word-separator') + cd.s('notification-part-insection', section.headline) :
'';
let mayBeRelevantString = cd.s('notification-newcomments-mayberelevant');
if (!mayBeRelevantString.startsWith(cd.mws('comma-separator'))) {
mayBeRelevantString = cd.mws('word-separator') + mayBeRelevantString;
}
// "that may be relevant to you" text is not needed when the section is watched and the user
// can clearly understand why they are notified.
const mayBeRelevant = section ? '' : mayBeRelevantString;
body = cd.s(
'notification-newcomments-desktop',
filteredComments.length,
where,
currentPageName,
mayBeRelevant
);
}
const notification = new Notification(mw.config.get('wgSiteName'), {
body,
// We use a tag so that there aren't duplicate notifications when the same page is opened in
// two tabs. (Seems it doesn't work? :-/)
tag: 'convenient-discussions-' + filteredComments[filteredComments.length - 1].id,
});
notification.onclick = () => {
parent.focus();
// Just in case, old browsers. TODO: delete?
window.focus();
this.emit('desktopNotificationClick');
this.reload({
commentIds: [comment.id],
closeNotificationsSmoothly: false,
});
};
},
/**
* Update the data about added comments (new comments added while the page was idle), update page
* components accordingly, show notifications.
*
* @param {import('./CommentSkeleton').CommentSkeletonLike[]} all
* @param {import('./CommentSkeleton').CommentSkeletonLike[]} relevant
*/
updateAddedComments(all, relevant) {
this.addedCommentCount = all.length;
this.areRelevantCommentsAdded = Boolean(relevant.length);
if (relevant.length) {
this.relevantAddedCommentIds = relevant.map((comment) => comment.id);
} else if (all.length) {
this.relevantAddedCommentIds = all.map((comment) => comment.id);
}
this.emit('addedCommentsUpdate', {
all,
relevant,
bySection: Comment.groupBySection(all),
});
this.updatePageTitle();
const commentsToNotifyAbout = relevant
.filter((comment) => !this.commentsNotifiedAbout.some((cna) => cna.id === comment.id));
this.showRegularNotification(commentsToNotifyAbout);
this.showDesktopNotification(commentsToNotifyAbout);
this.commentsNotifiedAbout.push(...commentsToNotifyAbout);
},
/**
* _For internal use._ Update the page title to show:
* - What state the page is in according to the user's action (replying, editing, starting a
* section or subsection, or none).
* - The number of comments added to the page since it was loaded. If used without parameters,
* restore the previous value (if could be changed by the browser when the "Back" button is
* clicked).
*/
updatePageTitle() {
let title = this.originalPageTitle;
const lastActiveCommentForm = commentFormRegistry.getLastActive();
if (lastActiveCommentForm) {
const ending = lastActiveCommentForm
.getTarget()
.getCommentFormMethodName(lastActiveCommentForm.getMode())
.toLowerCase();
title = cd.s(`page-title-${ending}`, title);
}
if (this.addedCommentCount === 0) {
// A hack for Chrome (at least) for cases when the "Back" button of the browser is clicked.
document.title = '';
}
const relevantMark = this.areRelevantCommentsAdded ? '*' : '';
document.title = title.replace(
/^(?:\(\d+\*?\) )?/,
this.addedCommentCount ?
`(${this.addedCommentCount}${relevantMark}) ` :
''
);
},
/**
* Get the IDs of the comments that should be jumped to after reloading the page.
*
* @type {?(string[])}
*/
getRelevantAddedCommentIds() {
return this.relevantAddedCommentIds;
},
/**
* _For internal use._ If every changed comment on the page has been seen and there are no new
* comments on the page that are not displayed, mark the page as read.
*/
maybeMarkPageAsRead() {
if (
!this.addedCommentCount &&
commentRegistry.getAll().every((comment) => !comment.willFlashChangedOnSight) &&
this.lastCheckedRevisionId
) {
cd.page.markAsRead(this.lastCheckedRevisionId);
}
},
/**
* Create an appropriate {@link Subscriptions} singleton based on the user settings.
*
* @returns {import('./Subscriptions').default}
*/
getSubscriptionsInstance() {
this.subscriptionsInstance ||= new (
settings.get('useTopicSubscription') ? DtSubscriptions : LegacySubscriptions
)();
return this.subscriptionsInstance;
},
/**
* _For internal use._ Bind a click handler to every known "Add topic" button out of our
* control (and update the behavior of the native "Add topic" button).
*/
connectToAddTopicButtons() {
this.$addTopicButtons = $(
[
'#ca-addsection a',
'.cd-addTopicButton a',
'a.cd-addTopicButton',
'a[href*="section=new"]',
'a[href*="Special:NewSection/"]',
'a[href*="Special:Newsection/"]',
'a[href*="special:newsection/"]',
'.commentbox input[type="submit"]',
'.createbox input[type="submit"]',
]
.concat(cd.config.addTopicButtonSelectors)
.join(', ')
)
.filter(function () {
const $button = $(this);
// When DT's new topic tool is enabled
if (
mw.util.getParamValue('section') === 'new' &&
$button.parent().attr('id') !== 'ca-addsection' &&
!$button.closest(this.$root).length
) {
return false;
}
let pageName;
let url;
if ($button.is('a')) {
url = new URL($button.prop('href'));
pageName = getLastArrayElementOrSelf(url.searchParams.getAll('title'))
?.replace(/^Special:NewSection\//i, '');
} else if ($button.is('input')) {
pageName = $button
.closest('form')
.find('input[name="title"][type="hidden"]')
.val();
} else {
return false;
}
let page;
try {
page = pageRegistry.get(pageName);
} catch (e) {
return false;
}
if (page !== cd.page) {
return false;
}
if ($button.is('a')) {
url.searchParams.set('dtenable', 0);
$button.attr('href', url);
}
return true;
});
if (!$('#ca-addsection a').length && this.$addTopicButtons.length === 1) {
this.$emulatedAddTopicButton = $(mw.util.addPortletLink(
'p-views',
this.$addTopicButtons.attr('href'),
cd.s('addtopic'),
'ca-addsection',
cd.s('addtopicbutton-tooltip'),
'+',
'#ca-history'
));
this.$addTopicButtons = this.$addTopicButtons.add(
this.$emulatedAddTopicButton.children()
);
}
this.$addTopicButtons
// DT may add its handler (as adds to a "Start new discussion" button on 404 pages). DT's "Add
// topic" button click handler is trickier, see below.
.off('click')
.on('click.cd', this.handleAddTopicButtonClick.bind(this))
.filter(function () {
const $button = $(this);
return (
!cd.g.isDtNewTopicToolEnabled &&
!($button.is('a') && Number(mw.util.getParamValue('cdaddtopic', $button.attr('href'))))
);
})
.attr('title', cd.s('addtopicbutton-tooltip'));
$('#ca-addsection a').updateTooltipAccessKeys();
// In case DT's new topic tool is enabled, remove the handler of the "Add topic" button.
const dtHandler = $._data(document.body).events?.click
?.find((event) => event.selector?.includes('data-mw-comment'))
?.handler;
if (dtHandler) {
$(document.body).off('click', dtHandler);
}
},
/**
* Get the list of DiscussionTools threads that are related to subscribable (2-level) threads.
* This is updated on page reload.
*
* @returns {object[]}
*/
getDtSubscribableThreads() {
this.dtSubscribableThreads ||= mw.config.get('wgDiscussionToolsPageThreads')
?.concat(
flat(
mw.config.get('wgDiscussionToolsPageThreads')
.filter((thread) => thread.headingLevel === 1)
.map((thread) => thread.replies)
)
)
.filter((thread) => thread.headingLevel === 2);
return this.dtSubscribableThreads;
},
/**
* Check whether subscribing is disabled on this page despite it being an active page (because
* it's a user's own talk page).
*
* @returns {boolean}
*/
isSubscribingDisabled() {
return cd.page.isOwnTalkPage() && !['all', 'toMe'].includes(settings.get('desktopNotifications'));
},
};