src/commentRegistry.js

/**
 * Singleton storing data about comments on the page and managing them.
 *
 * @module commentRegistry
 */

import Comment from './Comment';
import TreeWalker from './TreeWalker';
import cd from './cd';
import commentFormRegistry from './commentFormRegistry';
import controller from './controller';
import settings from './settings';
import updateChecker from './updateChecker';
import { getPagesExistence } from './utils-api';
import { getCommonGender, reorderArray, unique } from './utils-general';
import { getExtendedRect, getHigherNodeAndOffsetInSelection } from './utils-window';
import visits from './visits';

// TODO: make into a class extending a generic registry.

export default {
  /**
   * List of comments.
   *
   * @type {Comment[]}
   * @private
   */
  items: [],

  /**
   * List of underlays.
   *
   * @type {Element[]}
   */
  underlays: [],

  /**
   * List of containers of layers.
   *
   * @type {Element[]}
   */
  layersContainers: [],

  /**
   * _For internal use._ Initialize the registry.
   */
  init() {
    this.reformatCommentsSetting = settings.get('reformatComments');

    controller
      .on('scroll', this.registerSeen.bind(this))
      .on('mutate', this.maybeRedrawLayers.bind(this))
      .on('resize', this.maybeRedrawLayers.bind(this))
      .on('mouseMove', this.maybeHighlightHovered.bind(this))
      .on('popState', (fragment) => {
        // Don't jump to the comment if the user pressed "Back"/"Forward" in the browser or if
        // history.pushState() is called from Comment#scrollTo() (after clicks on added (gray)
        // items in the TOC). A marginal state of this happening is when a page with a comment ID in
        // the fragment is opened and then a link with the same fragment is clicked.
        if (!Comment.isAnyId(fragment) || history.state?.cdJumpedToComment) return;

        this.getByAnyId(fragment, true)?.scrollTo();
      })
      .on('selectionChange', this.getSelectedComment.bind(this))
      .on('beforeReload', (passedData) => {
        // Stop all animations, clear all timeouts.
        this.items.forEach((comment) => {
          comment.stopAnimations();
        });

        // If the page is reloaded externally, its content is already replaced, so we won't break
        // anything if we remove the layers containers early. And we better do so to avoid comment
        // layers hanging around without their owner comments.
        if (passedData.isPageReloadedExternally) {
          this.resetLayers();
        }
      })
      .on('startReload', this.resetLayers.bind(this))
      .on('addedCommentsUpdate', ({ all }) => {
        this.addNewCommentsNotes(all);
      })
      .on('desktopNotificationClick', this.maybeRedrawLayers.bind(this, true));
    visits
      .on('process', this.registerSeen.bind(this));
    updateChecker
      // If the layers of deleted comments have been configured in Comment#unmarkAsChanged(), they
      // will prevent layers before them from being updated due to the "stop at the first three
      // unmoved comments" optimization in .maybeRedrawLayers(). So we just do the whole job here.
      .on('newChanges', this.maybeRedrawLayers.bind(this, true));
    commentFormRegistry
      .on('teardown', this.registerSeen.bind(this));
  },

  /**
   * _For internal use._ Perform some comment-related operations when the registry is filled, in
   * addition to those performed when each comment is added to the registry.
   */
  setup() {
    // This can be updated after an in-script page reload if the user agrees to this setting in the
    // onboarding popup (settings.maybeSuggestEnableCommentReformatting()).
    this.reformatCommentsSetting = settings.get('reformatComments');

    this.reformatTimestamps();
    this.findAndUpdateTableComments();
    this.adjustDom();
    this.handleDtTimestampsClick();
  },

  /**
   * Add a comment to the list.
   *
   * @param {Comment} item
   */
  add(item) {
    this.items.push(item);
  },

  /**
   * Get all comments on the page ordered the same way as in the DOM. It returns the original array,
   * so use `.slice()` when changing it.
   *
   * @returns {Comment[]}
   */
  getAll() {
    return this.items;
  },

  /**
   * Get a comment by index.
   *
   * @param {number} index Use a negative index to count from the end.
   * @returns {?Comment}
   */
  getByIndex(index) {
    if (index < 0) {
      index = this.items.length + index;
    }
    return this.items[index] || null;
  },

  /**
   * Get the number of comments.
   *
   * @returns {number}
   */
  getCount() {
    return this.items.length;
  },

  /**
   * Get comments by a condition.
   *
   * @param {Function} condition
   * @returns {Comment[]}
   */
  query(condition) {
    return this.items.filter(condition);
  },

  /**
   * Reset the comment list.
   */
  reset() {
    this.items = [];
  },

  /**
   * Set the {@link Comment#isNew} and {@link Comment#isSeen} properties to comments.
   *
   * @param {object} currentPageData Visits data for the current page.
   * @param {number} currentTime Unix timestamp.
   * @param {boolean} markAsReadRequested Have the user requested to mark all shown comments as
   *   read.
   * @returns {boolean} Whether there is a time conflict.
   */
  initNewAndSeen(currentPageData, currentTime, markAsReadRequested) {
    let timeConflict = false;
    const unseenComments = controller.getBootProcess().passedData.unseenComments;
    this.items.forEach((comment) => {
      const unseenComment = unseenComments?.find((c) => c.id === comment.id);
      const commentTimeConflict = comment.initNewAndSeen(
        currentPageData,
        currentTime,
        markAsReadRequested ? undefined : unseenComment,
        unseenComment?.isChangedSincePreviousVisit ? unseenComment.$changeNote : undefined
      );
      timeConflict ||= commentTimeConflict;
    });

    this.configureAndAddLayers((comment) => comment.isNew);

    return timeConflict;
  },

  /**
   * Configure and add layers for a group of comments.
   *
   * @param {Function} condition
   */
  configureAndAddLayers(condition) {
    const comments = this.items.filter(condition);

    const floatingRects = comments.length ?
      controller.getFloatingElements().map(getExtendedRect) :
      undefined;
    comments.forEach((comment) => {
      comment.configureLayers({
        add: false,
        update: false,
        floatingRects,
      });
    });

    // Faster to add them in one sequence.
    comments.forEach((comment) => {
      comment.addLayers();
    });
  },

  /**
   * Recalculate the offset of the highlighted comments' (usually, new or own) layers and redraw if
   * they've changed.
   *
   * @param {boolean} [redrawAll] Whether to redraw all layers and not stop at first three unmoved.
   */
  maybeRedrawLayers(redrawAll = false) {
    if (controller.isBooting() || (document.hidden && !redrawAll)) return;

    this.layersContainers.forEach((container) => {
      container.cdCouldHaveMoved = true;
    });

    let floatingRects;
    const comments = [];
    const rootBottom = controller.$root[0].getBoundingClientRect().bottom + window.scrollY;
    let notMovedCount = 0;

    // We go from the end and stop at the first _three_ comments that have not been misplaced. A
    // quirky reason for this is that the mouse could be over some comment making its underlay to be
    // repositioned immediately and therefore not appearing as misplaced to this procedure. Three
    // comments threshold should be more reliable.
    this.items.slice().reverse().some((comment) => {
      const shouldBeHighlighted = (
        !comment.isCollapsed &&
        (
          comment.isNew ||
          comment.isOwn ||
          comment.isTarget ||
          comment.isHovered ||
          comment.isDeleted ||

          // Need to generate a gray line to close the gaps between adjacent list item elements.
          comment.isLineGapped
        )
      );

      // Layers that ended up under the bottom of the page content and could be moving the page
      // bottom down.
      const isUnderRootBottom = comment.offset && comment.offset.bottom > rootBottom;

      if (comment.underlay && !shouldBeHighlighted && isUnderRootBottom) {
        comment.removeLayers();
      } else if (shouldBeHighlighted) {
        floatingRects ||= controller.getFloatingElements().map(getExtendedRect);
        const isMoved = comment.configureLayers({
          // If a comment was hidden, then became visible, we need to add the layers.
          add: true,

          update: false,
          floatingRects,
        });
        if (isMoved || redrawAll) {
          notMovedCount = 0;
          comments.push(comment);
        } else if (isMoved === null) {
          comment.removeLayers();

        // Nested containers shouldn't count, the offset of layers inside them may be OK, unlike the
        // layers preceding them.
        } else if (comment.getLayersContainer().cdIsTopLayersContainer) {
          // isMoved === false
          notMovedCount++;
          if (notMovedCount === 2) {
            return true;
          }
        }
      }
      return false;
    });

    // It's faster to update the offsets separately in one sequence.
    comments.forEach((comment) => {
      comment.updateLayersOffset();
    });
  },

  /**
   * _For internal use._ Empty the underlay registry and the layers container elements. Done on page
   * reload.
   */
  resetLayers() {
    this.underlays = [];
    this.layersContainers.forEach((container) => {
      container.innerHTML = '';
    });
  },

  /**
   * _For internal use._ Mark comments that are currently in the viewport as read, and also
   * {@link Comment#flash flash} comments that are prescribed to flash.
   */
  registerSeen() {
    if (document.hidden) return;

    const commentInViewport = this.findInViewport();
    if (!commentInViewport) return;

    const registerIfInViewport = (comment) => {
      const isInViewport = comment.isInViewport();
      if (isInViewport) {
        comment.registerSeen();
        return false;
      } else if (isInViewport === false) {
        // isInViewport could also be `null`.
        return true;
      }
    };

    // Back
    this.items
      .slice(0, commentInViewport.index)
      .reverse()
      .some(registerIfInViewport);

    // Forward
    this.items
      .slice(commentInViewport.index)
      .some(registerIfInViewport);

    this.emit('registerSeen');
  },

  /**
   * Find any one comment inside the viewport.
   *
   * @param {string} [findClosestDirection] If there is no comment in the viewport, find the closest
   *   comment in the specified direction.
   * @returns {?Comment}
   */
  findInViewport(findClosestDirection) {
    // Reset the roughOffset property. It is used only within this method.
    this.items.forEach((comment) => {
      delete comment.roughOffset;
    });

    const viewportTop = window.scrollY + cd.g.bodyScrollPaddingTop;
    const viewportBottom = window.scrollY + window.innerHeight;

    // Visibility is checked in the sense that an element is visible on the page, not necessarily in
    // the viewport.
    const isVisible = (comment) => {
      comment.getOffset({ set: true });
      return Boolean(comment.roughOffset);
    };
    const findVisible = (direction, startIndex = 0, endIndex) => {
      let comments = reorderArray(this.items, startIndex, direction === 'backward');
      if (endIndex !== undefined) {
        comments = comments.filter((comment) => (
          direction === 'forward' ?
            comment.index >= startIndex && comment.index < endIndex :
            comment.index <= startIndex && comment.index > endIndex
        ));
      }
      return comments.find(isVisible) || null;
    };

    const firstVisibleComment = findVisible('forward');
    const lastVisibleComment = findVisible('backward', this.items.length - 1);
    if (!firstVisibleComment) {
      return null;
    }

    let searchArea = {
      top: firstVisibleComment,
      bottom: lastVisibleComment,
    };
    let comment = searchArea.top;
    let foundComment;

    const findClosest = (direction, searchArea, reverse = false) => (
      direction ?
        findVisible(
          direction,
          searchArea[(direction === 'forward' ? reverse : !reverse) ? 'top' : 'bottom'].index
        ) :
        null
    );

    // Here, we don't iterate over this.items as it may look like. We perform a so-called
    // interpolation search: narrow the search region by getting a proportion of the distance
    // between far away comments and the viewport and calculating the ID of the next comment based
    // on it; then, the position of that next comment is checked, and so on. this.items.length value
    // is used as an upper boundary for the number of cycle steps. It's more of a protection against
    // an infinite loop: the value is with a large margin and not practically reachable, unless when
    // there is only few comments. Usually the cycle finishes after a few steps.
    for (let i = 0; i < this.items.length; i++) {
      if (!comment.roughOffset) {
        comment.getOffset({ set: true });
        if (!comment.roughOffset) {
          comment = (
            findVisible('forward', comment.index, searchArea.bottom.index) ||
            findVisible('backward', comment.index, searchArea.top.index)
          );
          if (!comment) {
            foundComment = findClosest(findClosestDirection, searchArea);
            break;
          }
        }
      }

      if (comment.isInViewport(false)) {
        foundComment = comment;
        break;
      }

      if (
        comment.roughOffset &&

        (
          // The bottom edge of the viewport is above the first comment.
          (
            comment === firstVisibleComment &&
            viewportBottom < comment.roughOffset.bottomForVisibility
          ) ||

          // The top edge of the viewport is below the last comment.
          (comment === lastVisibleComment && viewportTop > comment.roughOffset.top)
        )
      ) {
        foundComment = findClosest(findClosestDirection, searchArea, true);
        break;
      }

      // Should usually be the case only if there is one comment on the page. But the proportion
      // below fails in rare cases too (see the console.warn call).
      if (searchArea.top === searchArea.bottom) {
        foundComment = findClosest(findClosestDirection, searchArea);
        break;
      }

      if (comment === firstVisibleComment) {
        comment = searchArea.bottom;
      } else {
        searchArea[viewportTop > comment.roughOffset.top ? 'top' : 'bottom'] = comment;

        // There's not a single comment in the viewport.
        if (searchArea.bottom.index - searchArea.top.index <= 1) {
          foundComment = findClosest(findClosestDirection, searchArea);
          break;
        }

        // Determine the ID of the next comment to check.
        const higherTop = searchArea.top.roughOffset.top;
        const lowerBottom = searchArea.bottom.roughOffset.bottomForVisibility;
        const proportion = (
          (viewportTop - higherTop) /
          ((lowerBottom - viewportBottom) + (viewportTop - higherTop))
        );
        if (proportion < 0 || proportion >= 1) {
          console.warn(
            'The proportion shouldn\'t be less than 0 or greater or equal to 1.',
            'proportion', proportion,
            'searchArea', searchArea
          );
        }
        const index = Math.round(
          (searchArea.bottom.index - searchArea.top.index - 1) * proportion +
          searchArea.top.index +
          0.5
        );
        comment = this.items[index];
      }
    }

    return foundComment || null;
  },

  /**
   * Handles the `mousemove` and `mouseover` events and highlights hovered comments even when the
   * cursor is between comment parts, not over them. (An event handler for comment part elements
   * wouldn't be able to handle this space between.)
   *
   * @param {Event} e
   */
  maybeHighlightHovered(e) {
    if (this.reformatCommentsSetting) return;

    const isObstructingElementHovered = controller.isObstructingElementHovered();
    this.items
      .filter((comment) => comment.underlay)
      .forEach((comment) => {
        comment.updateHoverState(e, isObstructingElementHovered);
      });
  },

  /**
   * Get a comment by ID in the CD format.
   *
   * @param {string} id
   * @param {boolean} [impreciseDate=false] Comment date is inferred from the edit date (but these
   *   may be different). If `true`, we allow the time on the page to be 1-3 minutes less than the
   *   edit time.
   * @returns {?Comment}
   */
  getById(id, impreciseDate = false) {
    if (!this.items.length || !id) {
      return null;
    }

    const findById = (id) => this.items.find((comment) => comment.id === id);

    let comment = findById(id);
    if (!comment && impreciseDate) {
      const { date, author } = Comment.parseId(id) || {};
      for (let gap = 1; !comment && gap <= 3; gap++) {
        comment = findById(
          Comment.generateId(new Date(date.getTime() - cd.g.msInMin * gap), author)
        );
      }
    }

    return comment || null;
  },

  /**
   * Get a comment by a comment ID in the DiscussionTools format.
   *
   * @param {string} id
   * @param {boolean} [returnComponents=false] Whether to return the constituents of the ID (as an
   *   object) together with a comment.
   * @returns {?(Comment|object)}
   */
  getByDtId(id, returnComponents = false) {
    const data = Comment.parseDtId(id);
    if (!data) {
      return null;
    }

    let comments = this.items.filter((comment) => (
      comment.date &&
      comment.date.getTime() === data.date.getTime() &&
      comment.author.getName() === data.author
    ));

    let comment;
    if (comments.length === 1) {
      comment = comments[0];
    } else if (comments.length > 1) {
      comments = comments.filter((comment) => (
        comment.getParent()?.date?.getTime() === data.parentDate?.getTime() &&
        comment.getParent()?.author.getName() === data.parentAuthor &&
        (!data.sectionIdBeginning || comment.section?.id.startsWith(data.sectionIdBeginning))
      ));
      comment = comments.length === 1 ? comments[0] : comments[data.index || 0];
    }

    if (returnComponents) {
      data.comment = comment;
      return data;
    }

    return comment;
  },

  /**
   * Get a comment by a comment ID in the CD or DiscussionTools format.
   *
   * @param {string} id
   * @param {boolean} [impreciseDate=false] (For CD IDs.) Comment date is inferred from the edit
   *   date (but these may be different). If `true`, we allow the time on the page to be 1-3 minutes
   *   less than the edit time.
   * @returns {?Comment}
   */
  getByAnyId(id, impreciseDate = false) {
    return Comment.isId(id) ?
      this.getById(id, impreciseDate) :
      this.getByDtId(id);
  },

  /**
   * _For internal use._ Filter out floating and hidden elements from all the comments'
   * {@link CommentSkeleton#highlightables highlightables}, change their attributes, and update the
   * comments' level and parent elements' level classes.
   */
  reviewHighlightables() {
    this.items.forEach((comment) => {
      comment.reviewHighlightables();
      comment.isLineGapped = comment.highlightables.length > 1 && comment.level > 0;
    });
  },

  /**
   * _For internal use._ Add new comments notifications to threads and sections.
   *
   * @param {Map} newComments
   */
  addNewCommentsNotes(newComments) {
    controller.saveRelativeScrollPosition();

    this.items.forEach((comment) => {
      comment.subitemList.remove('newCommentsNote');
    });

    // Section-level replies notes.
    $('.cd-thread-newCommentsNote').remove();

    const newCommentsByParent = new Map();
    newComments.forEach((comment) => {
      let key;
      if (comment.parent) {
        key = comment.parentMatch;
      } else {
        // If there is no section match, use the ancestor sections' section match.
        for (let s = comment.section; s && !key; s = s.parent) {
          key = s.match;
        }
      }

      // Indirect comment children and comments out of section
      if (!key) return;

      if (!newCommentsByParent.get(key)) {
        newCommentsByParent.set(key, []);
      }
      newCommentsByParent.get(key).push(comment);
    });

    const newCommentIndexes = newComments.map((comment) => comment.index);
    newCommentsByParent.forEach((comments, parent) => {
      if (parent instanceof Comment) {
        this.addNewCommentsNote(parent, comments, 'thread', newCommentIndexes);
      } else {
        // Add notes for level 0 comments and their children and the rest of the comments (for
        // example, level 1 comments without a parent and their children) separately.
        const sectionComments = comments
          .filter((comment) => comment.logicalLevel === 0)
          .reduce((arr, child) => (
            this.searchForNewCommentsInSubtree(child, arr, newCommentIndexes)
          ), []);
        const threadComments = comments.filter((comment) => !sectionComments.includes(comment));
        this.addNewCommentsNote(parent, sectionComments, 'section', newCommentIndexes);
        this.addNewCommentsNote(parent, threadComments, 'thread', newCommentIndexes);
      }
    });

    controller.restoreRelativeScrollPosition();
  },

  /**
   * Add an individual new comments notification to a thread or section.
   *
   * @param {import('./Comment').default|import('./Section').default} parent
   * @param {import('./CommentSkeleton').default[]} childComments
   * @param {'thread'|'section'} type
   * @param {import('./CommentSkeleton').default[]} newCommentIndexes
   * @private
   */
  addNewCommentsNote(parent, childComments, type, newCommentIndexes) {
    if (!childComments.length) return;

    const descendantComments = parent instanceof Comment ?
      childComments.reduce((arr, child) => (
        this.searchForNewCommentsInSubtree(child, arr, newCommentIndexes)
      ), []) :
      childComments;

    const authors = descendantComments
      .map((comment) => comment.author)
      .filter(unique);
    const button = new OO.ui.ButtonWidget({
      label: cd.s(
        type === 'thread' ? 'thread-newcomments' : 'section-newcomments',
        descendantComments.length,
        authors.length,
        authors.map((author) => author.getName()).join(cd.mws('comma-separator')),
        getCommonGender(authors)
      ),
      framed: false,
      classes: ['cd-button-ooui'],
    });
    button.on('click', () => {
      controller.reload({
        commentIds: descendantComments.map((comment) => comment.id),
        pushState: true,
      });
    });

    if (parent instanceof Comment) {
      button.$element.addClass('cd-thread-button');
      const { $wrappingItem } = parent.addSubitem('newCommentsNote', 'bottom');
      $wrappingItem
        .addClass('cd-thread-button-container cd-thread-newCommentsNote')
        .append(button.$element);

      // Update the collapsed range for the thread.
      if (parent.thread?.isCollapsed) {
        parent.thread.expand();
        parent.thread.collapse(null, true);
      }
    } else if (type === 'thread' && parent.$replyButtonWrapper) {
      button.$element.addClass('cd-thread-button');
      const tagName = parent.$replyButtonContainer.prop('tagName') === 'DL' ? 'dd' : 'li';
      $(`<${tagName}>`)
        .addClass('cd-thread-button-container cd-thread-newCommentsNote')
        .append(button.$element)
        .insertBefore(parent.$replyButtonWrapper);
    } else {
      button.$element.addClass('cd-section-button');
      (
        type === 'section' ?
          $('<div>').append(button.$element) :
          $('<dl>').append($('<dd>').append(button.$element))
      )
        .addClass('cd-thread-button-container cd-thread-newCommentsNote')
        .insertAfter(
          parent.$addSubsectionButtonContainer && !parent.getChildren().length ?
            parent.$addSubsectionButtonContainer :
            parent.$replyButtonContainer || parent.lastElementInFirstChunk
        );
    }
  },

  /**
   * _For internal use._ Reformat the comments (moving the author and date up and links down) if the
   * relevant setting is enabled.
   */
  async reformatComments() {
    if (!this.reformatCommentsSetting) return;

    $(document.body).addClass('cd-reformattedComments');
    if (!cd.page.exists()) return;

    const pagesToCheckExistence = [];
    this.items.forEach((comment) => {
      pagesToCheckExistence.push(...comment.replaceSignatureWithHeader());
      comment.addMenu();
    });

    // Check existence of user and user talk pages and apply respective changes to elements.
    const pageNamesToLinks = {};
    pagesToCheckExistence.forEach((page) => {
      pageNamesToLinks[page.pageName] ||= [];
      pageNamesToLinks[page.pageName].push(page.link);
    });
    const pagesExistence = await getPagesExistence(Object.keys(pageNamesToLinks));
    Object.keys(pagesExistence).forEach((name) => {
      pageNamesToLinks[name].forEach((link) => {
        link.title = pagesExistence[name].normalized;
        if (pagesExistence[name].exists) {
          link.href = mw.util.getUrl(pagesExistence[name].normalized);
        } else {
          link.classList.add('new');
          link.href = mw.util.getUrl(name, {
            action: 'edit',
            redlink: 1,
          });
        }
      });
    });
  },

  /**
   * _For internal use._ Change the format of the comment timestamps according to the settings.
   */
  reformatTimestamps() {
    if (!cd.g.areTimestampsAltered) return;

    this.items.forEach((comment) => {
      comment.reformatTimestamp();
    });
  },

  /**
   * Change the state of all comments to unselected.
   *
   * @private
   */
  resetSelectedComment() {
    const comment = this.items.find((comment) => comment.isSelected);
    if (comment) {
      comment.setSelected(false);
      this.emit('unselected', comment);
    }
  },

  /**
   * Determine which comment on the page is selected.
   *
   * @returns {?Comment}
   */
  getSelectedComment() {
    const selection = window.getSelection();
    const selectionText = selection.toString().trim();
    let comment;
    if (selectionText) {
      const { higherNode } = getHigherNodeAndOffsetInSelection(selection);
      const treeWalker = new TreeWalker(controller.rootElement, null, false, higherNode);
      let commentIndex;
      do {
        commentIndex = treeWalker.currentNode.dataset?.cdCommentIndex;
      } while (commentIndex === undefined && treeWalker.parentNode());
      if (commentIndex !== undefined) {
        comment = this.items[commentIndex];
        if (comment) {
          if (!comment.isSelected) {
            this.resetSelectedComment();
            comment.setSelected(true);
            this.emit('select', comment);
          }
        } else {
          this.resetSelectedComment();
        }
      } else {
        this.resetSelectedComment();
      }
    } else {
      this.resetSelectedComment();
    }
    return comment || null;
  },

  /**
   * Find a previous comment by time by the specified author within a 1-day window.
   *
   * @param {Date} date
   * @param {string} author
   * @returns {Comment}
   * @private
   */
  findPriorComment(date, author) {
    return this.items
      .filter((comment) => (
        comment.author.getName() === author &&
        comment.date &&
        comment.date < date &&
        comment.date.getTime() > date.getTime() - cd.g.msInDay
      ))
      .sort((c1, c2) => c1.date.getTime() - c2.date.getTime())
      .slice(-1)[0];
  },

  /**
   * _For internal use._ Add available DiscussionTools IDs to respective comments.
   *
   * @param {string[]} ids
   */
  setDtIds(ids) {
    ids.forEach((id) => {
      const comment = this.getByDtId(id);
      if (comment) {
        comment.dtId = id;
      }
    });
  },

  /**
   * _For internal use._ Set the {@link Comment#isTableComment} property for each "table comment",
   * i.e. a comment that is (or its signature is) inside a table containing only that comment.
   */
  findAndUpdateTableComments() {
    // Faster than doing it for every individual comment.
    controller.rootElement
      .querySelectorAll('table.cd-comment-part .cd-signature, .cd-comment-part > table .cd-signature')
      .forEach((signature) => {
        const index = signature.closest('.cd-comment-part').dataset.cdCommentIndex;
        if (index !== undefined) {
          this.items[index].isTableComment = true;
        }
      });
  },

  /**
   * Add comment's children, including indirect, into an array, if they are in the array of all new
   * comments.
   *
   * @param {import('./CommentSkeleton').default} childComment
   * @param {import('./CommentSkeleton').default[]} newCommentsInSubtree
   * @param {number[]} newCommentIndexes
   * @returns {import('./CommentSkeleton').default[]}
   * @private
   */
  searchForNewCommentsInSubtree(childComment, newCommentsInSubtree, newCommentIndexes) {
    if (newCommentIndexes.includes(childComment.index)) {
      newCommentsInSubtree.push(childComment);
    }
    childComment.children.forEach((childComment) => {
      this.searchForNewCommentsInSubtree(childComment, newCommentsInSubtree, newCommentIndexes);
    });
    return newCommentsInSubtree;
  },

  /**
   * _For internal use._ Perform some DOM-related tasks after parsing comments.
   */
  adjustDom() {
    this.mergeAdjacentCommentLevels();
    this.mergeAdjacentCommentLevels();
    if (
      controller.rootElement.querySelector('.cd-commentLevel:not(ol) + .cd-commentLevel:not(ol)')
    ) {
      console.warn('.cd-commentLevel adjacencies have left.');
    }

    this.items.slice(1).forEach((comment) => {
      comment.maybeSplitParent();
    });
  },

  /**
   * Remove DT's event listener from its comment links and attach ours.
   *
   * @private
   */
  handleDtTimestampsClick() {
    if (this.reformatCommentsSetting) return;

    this.items.forEach((comment) => {
      comment.handleDtTimestampClick();
    });
  },

  /**
   * Combine two adjacent `.cd-commentLevel` elements into one, recursively going deeper in terms of
   * nesting level.
   *
   * @private
   */
  mergeAdjacentCommentLevels() {
    const levels = controller.rootElement
      .querySelectorAll('.cd-commentLevel:not(ol) + .cd-commentLevel:not(ol)');
    if (!levels.length) return;

    const isOrHasCommentLevel = (el) => (
      (el.classList.contains('cd-commentLevel') && el.tagName !== 'OL') ||
      el.querySelector('.cd-commentLevel:not(ol)')
    );

    [...levels].forEach((bottomElement) => {
      const topElement = bottomElement.previousElementSibling;

      // If the previous element was removed in this cycle. (Or it could be absent for some other
      // reason? I can confirm that I witnessed a case where the element was absent, but didn't pay
      // attention why unfortunately.)
      if (!topElement) return;

      let currentTopElement = topElement;
      let currentBottomElement = bottomElement;
      do {
        const topTag = currentTopElement.tagName;
        const bottomInnerTags = {};
        if (topTag === 'UL') {
          bottomInnerTags.DD = 'LI';
        } else if (topTag === 'DL') {
          bottomInnerTags.LI = 'DD';
        }

        let firstMoved;
        if (isOrHasCommentLevel(currentTopElement)) {
          const firstElementChild = currentBottomElement.firstElementChild;

          /*
            Avoid collapsing adjacent <li>s and <dd>s if we deal with a structure like this:

              <li>
                <div>Comment</div>
                <ul>Replies</ul>
              </li>
              <li>
                <div>Comment</div>
                <ul>Replies</ul>
              </li>
          */
          if (['DL', 'DD', 'UL', 'LI'].includes(firstElementChild.tagName)) {
            while (currentBottomElement.childNodes.length) {
              let child = currentBottomElement.firstChild;
              if (child.tagName) {
                if (bottomInnerTags[child.tagName]) {
                  child = this.changeElementType(child, bottomInnerTags[child.tagName]);
                }
                firstMoved ??= child;
              } else if (firstMoved === undefined && child.textContent.trim()) {
                // Don't fill the "firstMoved" variable which is used further to merge elements if
                // there is a non-empty text node between. (An example that is now fixed:
                // https://ru.wikipedia.org/wiki/Википедия:Форум/Архив/Викиданные/2018/1_полугодие#201805032155_NBS,
                // but other can be on the loose.) Instead, wrap the text node into an element to
                // prevent it from being ignored when searching next time for adjacent .commentLevel
                // elements. This could be seen only as an additional precaution, since it doesn't
                // fix the source of the problem: the fact that a bare text node is (probably) a
                // part of the reply. It shouldn't be happening.
                firstMoved = null;
                const newChild = document.createElement('span');
                newChild.appendChild(child);
                child = newChild;
              }
              currentTopElement.appendChild(child);
            }
            currentBottomElement.remove();
          }
        }

        currentBottomElement = firstMoved;
        currentTopElement = firstMoved?.previousElementSibling;
      } while (
        currentTopElement &&
        currentBottomElement &&
        isOrHasCommentLevel(currentBottomElement)
      );
    });
  },

  /**
   * Replace an element with an identical one but with another tag name, i.e. move all child nodes,
   * attributes, and some bound events to a new node, and also reassign references in some variables
   * and properties to this element. Unfortunately, we can't just change the element's `tagName` to
   * do that.
   *
   * @param {Element} element
   * @param {string} newType
   * @returns {Element}
   */
  changeElementType(element, newType) {
    const newElement = document.createElement(newType);
    while (element.firstChild) {
      newElement.appendChild(element.firstChild);
    }
    [...element.attributes].forEach((attribute) => {
      newElement.setAttribute(attribute.name, attribute.value);
    });

    // If this element is a part of a comment, replace it in the Comment object instance.
    const commentIndex = element.getAttribute('data-cd-comment-index');
    if (commentIndex !== null) {
      this.items[Number(commentIndex)].replaceElement(element, newElement);
    } else {
      element.parentNode.replaceChild(newElement, element);
    }

    controller.replaceScrollAnchorElement(element, newElement);

    return newElement;
  },

  /**
   * _For internal use._ Add the `'cd-connectToPreviousItem'` class to some item elements to
   * visually connect threads broken by some intervention.
   */
  connectBrokenThreads() {
    const items = [];

    controller.rootElement
      .querySelectorAll('dd.cd-comment-part-last + dd, li.cd-comment-part-last + li')
      .forEach((el) => {
        if (el.firstElementChild?.classList.contains('cd-commentLevel')) {
          items.push(el);
        }
      });

    // When editing https://en.wikipedia.org/wiki/Wikipedia:Village_pump_(technical)/Archive_212#c-PrimeHunter-20240509091500-2605:A601:AAF7:3700:A1D7:26C1:E273:28CF-20240509055600
    controller.rootElement
      .querySelectorAll('dd.cd-comment-part:not(.cd-comment-part-last) + dd > .cd-comment-part:first-child, li.cd-comment-part:not(.cd-comment-part-last) + li > .cd-comment-part:first-child')
      .forEach((el) => {
        items.push(el.parentNode);
      });

    // https://commons.wikimedia.org/wiki/User_talk:Jack_who_built_the_house/CD_test_cases#202009202110_Example
    controller.rootElement
      .querySelectorAll('.cd-comment-replacedPart.cd-comment-part-last')
      .forEach((el) => {
        const possibleItem = el.parentNode.nextElementSibling;
        if (possibleItem?.firstElementChild?.classList.contains('cd-commentLevel')) {
          items.push(possibleItem);
        }
      });

    // https://commons.wikimedia.org/wiki/User_talk:Jack_who_built_the_house/CD_test_cases#Image_breaking_a_thread
    controller.rootElement
      .querySelectorAll('.cd-commentLevel + .thumb + .cd-commentLevel > li')
      .forEach((el) => {
        items.push(el);
      });

    if (controller.areThereOutdents()) {
      // Outdent templates. We could instead merge adjacent <li>s, but if there is a {{outdent|0}}
      // template and the whole <li> of the parent is considered a comment part, then we can't do
      // that.
      controller.rootElement
        .querySelectorAll(`.cd-commentLevel > li + li > .${cd.config.outdentClass}, .cd-commentLevel > dd + dd > .${cd.config.outdentClass}`)
        .forEach((el) => {
          items.push(el.parentNode);
        });
      controller.rootElement
        .querySelectorAll(`.cd-commentLevel > li + .cd-comment-outdented, .cd-commentLevel > dd + .cd-comment-outdented`)
        .forEach((el) => {
          items.push(el);
        });
    }

    items.forEach((item) => {
      item.classList.add('cd-connectToPreviousItem');
    });
  },
};