src/Thread.js

import Button from './Button';
import CdError from './CdError';
import ElementsTreeWalker from './ElementsTreeWalker';
import PrototypeRegistry from './PrototypeRegistry';
import StorageItem from './StorageItem';
import cd from './cd';
import commentRegistry from './commentRegistry';
import controller from './controller';
import settings from './settings';
import updateChecker from './updateChecker';
import { loadUserGenders } from './utils-api';
import { defined, getCommonGender, isHeadingNode, removeFromArrayIfPresent, unique } from './utils-general';
import { getExtendedRect, getRangeContents, getVisibilityByRects, isCmdModifierPressed } from './utils-window';

/**
 * Class representing a comment thread object.
 */
class Thread {
  /**
   * Create a comment thread object.
   *
   * @param {import('./Comment').default} rootComment Root comment of the thread.
   */
  constructor(rootComment) {
    this.documentMouseMoveHandler = this.handleDocumentMouseMove.bind(this);
    this.quitNavModeHandler = this.quitNavMode.bind(this);

    /**
     * Root comment of the thread.
     *
     * @type {import('./Comment').default}
     * @private
     */
    this.rootComment = rootComment;

    /**
     * List of comments in the thread (logically, not visually).
     *
     * @type {import('./Comment').default}
     * @private
     */
    this.comments = [rootComment, ...rootComment.getChildren(true)];

    /**
     * Last comment of the thread (logically, not visually).
     *
     * @type {import('./Comment').default}
     * @private
     */
    this.lastComment = this.comments.slice(-1)[0];

    /**
     * Number of comments in the thread.
     *
     * @type {number}
     * @private
     */
    this.commentCount = this.lastComment.index - this.rootComment.index + 1;

    /**
     * Whether the thread has outdented comments.
     *
     * @type {boolean}
     * @private
     */
    this.hasOutdents = (
      controller.areThereOutdents() &&
      this.comments.slice(1).some((comment) => comment.isOutdented)
    );

    /**
     * Last comment of the thread _visually_, not logically (differs from {@link Thread#lastComment}
     * if there are `{{outdent}}` templates in the thread).
     *
     * @type {import('./Comment').default}
     * @private
     */
    this.visualLastComment = this.hasOutdents ?
      rootComment.getChildren(true, true).slice(-1)[0] || rootComment :
      this.lastComment;

    /**
     * Fallback visual last comment. Used when `Thread#visualEndElement` may be hidden without
     * collapsing the thread. That usually means `Thread#visualEndElement` has the
     * `cd-connectToPreviousItem` class.
     *
     * @type {import('./Comment').default}
     * @private
     */
    this.visualLastCommentFallback = this.hasOutdents ?
      // `|| rootComment` part for a very weird case when an outdented comment is at the same level
      // as its parent.
      rootComment.getChildren(true, true, false).slice(-1)[0] || rootComment :

      this.lastComment;

    this.initBoundingElements();

    /**
     * Is the thread collapsed.
     *
     * @type {boolean}
     */
    this.isCollapsed = false;

    this.navMode = false;
    this.blockClickEvent = false;
  }

  /**
   * Set the `startElement`, `endElement`, `visualEndElement`, and `visualEndElementFallback`
   * properties.
   *
   * @throws {CdError}
   * @private
   */
  initBoundingElements() {
    let startElement;
    let endElement;
    let visualEndElement;
    let visualEndElementFallback;
    const firstNotHeadingElement = this.rootComment.elements.find((el) => !isHeadingNode(el));
    const highlightables = this.lastComment.highlightables;
    const visualHighlightables = this.visualLastComment.highlightables;
    const visualHighlightablesFallback = this.visualLastCommentFallback.highlightables;
    const nextForeignElement = commentRegistry.getByIndex(this.lastComment.index + 1)?.elements[0];

    if (this.rootComment.level === 0) {
      startElement = firstNotHeadingElement;
      visualEndElement = this.constructor.findEndElement(
        startElement,
        visualHighlightables,
        nextForeignElement
      );
      visualEndElementFallback = this.visualLastComment === this.visualLastCommentFallback ?
        visualEndElement :
        this.constructor.findEndElement(
          startElement,
          visualHighlightablesFallback,
          nextForeignElement
        );
      endElement = this.hasOutdents ?
        this.constructor.findEndElement(
          startElement,
          highlightables,
          nextForeignElement
        ) :
        visualEndElement;
    } else {
      // We could improve the positioning of the thread line to exclude the vertical space next to
      // an outdent template placed at a non-0 level by taking the first element as the start
      // element. But then we need to fix areTopAndBottomAligned() (calculate the last comment's
      // margins instead of using the first comment's) and utilsWindow.getRangeContents() (come up
      // with a treatment for the situation when the end element includes the start element).
      startElement = (
        this.constructor.findItemElement(
          firstNotHeadingElement,
          this.rootComment.level,
          nextForeignElement
        ) ||
        firstNotHeadingElement
      );
      const lastHighlightable = highlightables[highlightables.length - 1];

      if (this.hasOutdents) {
        const lastOutdentedComment = commentRegistry.getAll()
          .slice(0, this.lastComment.index + 1)
          .reverse()
          .find((comment) => comment.isOutdented);
        endElement = lastOutdentedComment.level === 0 ?
          this.constructor.findEndElement(
            startElement,
            highlightables,
            nextForeignElement
          ) :
          this.constructor.findItemElement(
            lastHighlightable,
            Math.min(lastOutdentedComment.level, this.rootComment.level),
            nextForeignElement
          );

        visualEndElement = this.constructor.findItemElement(
          visualHighlightables[visualHighlightables.length - 1],
          this.rootComment.level,
          nextForeignElement
        );
        visualEndElementFallback = this.visualLastComment === this.visualLastCommentFallback ?
          visualEndElement :
          this.constructor.findItemElement(
            visualHighlightablesFallback[visualHighlightablesFallback.length - 1],
            this.rootComment.level,
            nextForeignElement
          );
      } else {
        endElement = (
          this.constructor.findItemElement(
            lastHighlightable,
            this.rootComment.level,
            nextForeignElement
          ) ||
          lastHighlightable
        );

        visualEndElementFallback = visualEndElement = endElement;
      }
    }

    if (!startElement || !endElement || !visualEndElement || !visualEndElementFallback) {
      throw new CdError();
    }

    /**
     * Top element of the thread.
     *
     * @type {Element}
     * @private
     */
    this.startElement = startElement;

    /**
     * Bottom element of the thread (logically, not visually).
     *
     * @type {Element}
     * @private
     */
    this.endElement = endElement;

    /**
     * Bottom element of the thread _visually_, not logically (differs from
     * {@link Thread#endElement} if there are `{{outdent}}` templates in the thread).
     *
     * @type {Element}
     * @private
     */
    this.visualEndElement = visualEndElement;

    /**
     * Fallback visual end element. Used when `Thread#visualEndElement` may be hidden without
     * collapsing the thread. That usually means `Thread#visualEndElement` has the
     * `cd-connectToPreviousItem` class.
     *
     * @type {Element}
     * @private
     */
    this.visualEndElementFallback = visualEndElementFallback;
  }

  /**
   * Handle the `mouseenter` event on the click area.
   *
   * @param {Event} e
   * @param {boolean} [force=false]
   * @private
   */
  handleClickAreaHover(e, force = false) {
    if (this.constructor.navMode && !force) return;

    const highlight = () => {
      this.clickArea?.classList.add('cd-thread-clickArea-hovered');
    };

    if (force) {
      highlight();
    } else {
      this.highlightTimeout = setTimeout(highlight, 75);
    }
  }

  /**
   * Handle the `mouseleave` event on the click area.
   *
   * @private
   */
  handleClickAreaUnhover() {
    clearTimeout(this.highlightTimeout);
    this.clickArea?.classList.remove('cd-thread-clickArea-hovered');
  }

  /**
   * Handle the `mousedown` event on the click area.
   *
   * @param {Event} e
   * @private
   */
  handleClickAreaMouseDown(e) {
    if (this.navMode) return;

    // Middle button
    if (!this.rootComment.isCollapsed) {
      if (e.button === 1) {
        e.preventDefault();

        // Prevent hitting document's mousedown.cd listener we add in .enterNavMode().
        e.stopPropagation();

        this.enterNavMode(e.clientX, e.clientY);
      }

      // We also need the left button for touchpads, but need to wait until the user moves the
      // mouse.
      if (e.button === 0) {
        e.preventDefault();
        this.navFromY = e.clientY;
        this.navFromX = e.clientX;

        $(document).one('mouseup.cd', (e) => {
          e.preventDefault();
          delete this.navFromY;
          delete this.navFromX;
          $(document).off('mousemove.cd', this.documentMouseMoveHandler);
        });

        $(document).on('mousemove.cd', this.documentMouseMoveHandler);
      }
    }
  }

  /**
   * Handle the `mouseup` event on the click area.
   *
   * @param {Event} e
   * @private
   */
  handleClickAreaMouseUp(e) {
    if (this.navMode && e.button === 0) {
      // `mouseup` event comes before `click`, so we need to block collapsing the thread is the user
      // clicked the left button to navigate threads.
      this.blockClickEvent = true;
    }

    // Middle or left button.
    if (e.button === 1 || e.button === 0) {
      this.handleClickAreaHover(undefined, true);
    }

    // Middle button
    if (e.button === 1 && this.navMode && !this.hasMouseMoved(e)) {
      this.rootComment.scrollTo({ alignment: 'top' });
    }
  }

  /**
   * Has the mouse moved enough to consider it a navigation gesture and not a click with an
   * insignificant mouse movement between pressing and releasing a button.
   *
   * @param {MouseEvent} e
   * @returns {boolean}
   */
  hasMouseMoved(e) {
    return Math.abs(e.clientX - this.navFromX) >= 5 || Math.abs(e.clientY - this.navFromY) >= 5;
  }

  /**
   * Enter the navigation mode.
   *
   * @param {number} fromX
   * @param {number} fromY
   * @param {boolean} grab Grab mode - reverse up and down (on touchpads).
   * @private
   */
  enterNavMode(fromX, fromY, grab = false) {
    this.handleClickAreaUnhover();
    this.constructor.navMode = this.navMode = true;
    this.navFromY = fromY;
    this.navFromX = fromX;
    this.navGrab = grab;

    $(document)
      .on('mousemove.cd', this.documentMouseMoveHandler)
      .one('mouseup.cd mousedown.cd', this.quitNavModeHandler);
    $(window)
      .one('blur.cd', this.quitNavModeHandler);
    $(document.body).addClass('cd-thread-navMode-updown');
  }

  /**
   * Handle the `mousemove` event when the navigation mode is active.
   *
   * @param {Event} e
   * @private
   */
  handleDocumentMouseMove(e) {
    if (!this.navMode) {
      // This implies `this.navFromX !== undefined`, .navFromX set in .handleClickAreaMouseDown()

      if (this.hasMouseMoved(e)) {
        $(document).off('mousemove.cd', this.documentMouseMoveHandler);
        this.enterNavMode(this.navFromX, this.navFromY, true);
      }
      return;
    }

    const delta = e.clientY - this.navFromY;
    const target = this.getNavTarget(delta);
    if (target && this.navScrolledTo !== target) {
      target.scrollTo({
        alignment: target.logicalLevel === this.rootComment.logicalLevel ? 'top' : 'bottom',
      });
      this.navScrolledTo = target;
    }
  }

  /**
   * Update the document cursor based on its position relative to the initial position in navigation
   * mode.
   *
   * @param {number} direction `-1`, `0`, or `1`.
   * @private
   */
  updateCursor(direction) {
    $(document.body)
      .toggleClass('cd-thread-navMode-up', direction === -1)
      .toggleClass('cd-thread-navMode-updown', direction === 0)
      .toggleClass('cd-thread-navMode-down', direction === 1);
  }

  /**
   * Given the cursor position relative to the initial position, return the target comment to
   * navigate to. Also update the cursor look.
   *
   * @param {number} delta
   * @returns {?import('./Comment').default}
   * @private
   */
  getNavTarget(delta) {
    const stepSize = 80;

    if (this.navGrab) {
      delta *= -1;
    }

    if (!this.navInitialDirection) {
      if (-15 < delta && delta < 15) {
        this.updateCursor(0);
        return null;
      }

      if (Math.abs(delta / stepSize) < 1) {
        if (delta < 0) {
          this.updateCursor(-1);
          return this.rootComment;
        }

        this.updateCursor(1);
        return this.lastComment;
      }
    }

    const steps = (
      Math.sign(delta) *
      (
        Math.floor(Math.abs(delta / stepSize)) +
        Number(Math.sign(delta) === -this.navInitialDirection
      ))
    );
    const comments = commentRegistry.getAll();
    let target = this.rootComment;
    for (
      let i = this.rootComment.index + Math.sign(delta), step = 0;
      i >= 0 && i < comments.length && step !== steps;
      i += Math.sign(delta)
    ) {
      const comment = comments[i];
      if (
        // We need to check the logical level too, because there can be comments with no parents on
        // logical levels other than 0.
        this.rootComment.logicalLevel === comment.logicalLevel &&
        this.rootComment.getParent() === comment.getParent()
      ) {
        target = comment;
        step += Math.sign(delta);
      } else if (steps > 0 && comment === target.thread?.lastComment) {
        // Use the last comment of the last sibling thread as a fallback when scrolling down
        target = comment;
      }
    }

    if (target !== this.rootComment && !this.navInitialDirection) {
      // If we scrolled to another thread once, don't scroll to the last comment of this thread
      // again and shift the pixels so that 0 steps is now 0...stepSize or 0...-stepSize and not
      // -stepSize...stepSize (so that the mouse is moved evenly and not "100 pixels, 100 pixels,
      // 200 pixels, 100 pixels, ...").
      this.navInitialDirection = Math.sign(steps);
    }

    this.updateCursor(Math.sign(steps));

    return target;
  }

  /**
   * Quit navigation mode and remove its traces.
   *
   * @private
   */
  quitNavMode() {
    this.constructor.navMode = this.navMode = false;
    delete this.navFromY;
    delete this.navFromX;
    delete this.navScrolledTo;
    delete this.navInitialDirection;
    delete this.navGrab;
    $(document)
      .off('mousemove.cd', this.documentMouseMoveHandler)
      .off('mouseup.cd mousedown.cd', this.quitNavModeHandler);
    $(document.body).removeClass('cd-thread-navMode-updown cd-thread-navMode-up cd-thread-navMode-down');
  }

  /**
   * Handle the `click` event on the click area.
   *
   * @private
   */
  handleClickAreaClick() {
    if (this.blockClickEvent) {
      this.blockClickEvent = false;
      return;
    }

    if (!this.clickArea.classList.contains('cd-thread-clickArea-hovered')) return;

    this.toggle();
  }

  /**
   * Create a thread line with a click area around.
   *
   * @private
   */
  createLine() {
    /**
     * Click area of the thread line.
     *
     * @type {Element}
     * @private
     */
    this.clickArea = this.constructor.prototypes.get('clickArea');

    this.clickArea.title = cd.s('thread-tooltip');

    // Add some debouncing so that the user is not annoyed by the cursor changing its form when
    // moving across thread lines.
    this.clickArea.onmouseenter = this.handleClickAreaHover.bind(this);
    this.clickArea.onmouseleave = this.handleClickAreaUnhover.bind(this);

    this.clickArea.onclick = this.handleClickAreaClick.bind(this);
    this.clickArea.onmousedown = this.handleClickAreaMouseDown.bind(this);
    this.clickArea.onmouseup = this.handleClickAreaMouseUp.bind(this);

    /**
     * Thread line.
     *
     * @type {Element}
     * @private
     */
    this.line = this.clickArea.firstChild;

    if (this.endElement !== this.visualEndElement) {
      let areOutdentedCommentsShown = false;
      for (let i = this.rootComment.index; i <= this.lastComment.index; i++) {
        const comment = commentRegistry.getByIndex(i);
        if (comment.isOutdented) {
          areOutdentedCommentsShown = true;
        }
        if (comment.thread?.isCollapsed) {
          i = comment.thread.lastComment.index;
          continue;
        }
      }
      if (areOutdentedCommentsShown) {
        this.line.classList.add('cd-thread-line-extended');
      }
    }
  }

  /**
   * Get the end element of the thread, revising it based on
   * {@link Comment#subitemList comment subitems}.
   *
   * @param {boolean} visual Use the visual thread end.
   * @returns {?Element} Logically, should never return `null`, unless something extraordinary
   *   happens that makes the return value of `Thread.findItemElement()` `null`.
   * @private
   */
  getAdjustedEndElement(visual) {
    /*
      In a structure like this:

        Comment
          Reply
            Comment form 1
            Reply
              Reply
                Comment form 2
              New comments note 1
            New comments note 2

      - we need to calculate the end element accurately. In this case, it is "New comments note 2",
      despite the fact that it is not a subitem of the last comment. (Subitems of 0-level comments
      are handled by a different mechanism, see `Thread.findEndElement()`.)
    */
    let lastComment;
    let endElement;
    if (visual) {
      lastComment = this.visualLastComment;
      endElement = this.visualEndElement;
      if (
        endElement.classList.contains('cd-hidden') &&
        endElement.previousElementSibling?.classList.contains('cd-thread-expandNote')
      ) {
        endElement = endElement.previousElementSibling;
      }
      if (!getVisibilityByRects(endElement.getBoundingClientRect())) {
        endElement = this.visualEndElementFallback;

        if (!getVisibilityByRects(endElement.getBoundingClientRect()) && this.rootComment.editForm) {
          endElement = this.rootComment.editForm.getOutermostElement();
        }
      }
    } else {
      lastComment = this.lastComment;
      endElement = this.endElement;
    }

    const $lastSubitem = (
      (
        this.rootComment.level >= 1 ||

        // Catch special cases when a section has no "Reply in section" and "There are new comments
        // in this thread" button or the thread isn't the last thread starting with a 0-level
        // comment in the section.
        !endElement.classList.contains('cd-section-button-container')
      ) &&
      (
        this.rootComment.subitemList.get('newCommentsNote') ||
        (this.rootComment === lastComment && this.rootComment.subitemList.get('replyForm'))
      ) ||
      undefined
    );

    return $lastSubitem?.is(':visible') ?
      this.constructor.findItemElement($lastSubitem[0], this.rootComment.level) :
      endElement;
  }

  /**
   * Get the top element of the thread or its replacement.
   *
   * @returns {Element}
   * @private
   */
  getAdjustedStartElement() {
    if (this.isCollapsed) {
      return this.expandNote;
    }

    if (this.startElement.classList.contains('cd-hidden') && this.rootComment.editForm) {
      return this.rootComment.editForm.getOutermostElement();
    }

    return this.startElement;
  }

  /**
   * Get a list of users in the thread.
   *
   * @returns {import('./userRegistry').User[]}
   * @private
   */
  getUsers() {
    return [this.rootComment, ...this.rootComment.getChildren(true)]
      .map((comment) => comment.author)
      .filter(unique);
  }

  /**
   * Add an expand note when collapsing a thread.
   *
   * @param {Promise.<undefined>} [loadUserGendersPromise]
   * @private
   */
  addExpandNode(loadUserGendersPromise) {
    const element = this.constructor.prototypes.get('expandButton');
    const button = new Button({
      tooltip: cd.s('thread-expand-tooltip', cd.g.cmdModifier),
      action: this.onExpandNoteClick.bind(this),
      element: element,
      buttonElement: element.firstChild,
      labelElement: element.querySelector('.oo-ui-labelElement-label'),
    });
    const usersInThread = this.getUsers();
    const userList = usersInThread
      .map((author) => author.getName())
      .join(cd.mws('comma-separator'));
    const setLabel = (genderless) => {
      button.setLabel(cd.s(
        genderless ? 'thread-expand-label-genderless' : 'thread-expand-label',
        this.commentCount,
        usersInThread.length,
        userList,
        getCommonGender(usersInThread)
      ));
      button.element.classList.remove('cd-thread-button-invisible');
    };
    if (cd.g.genderAffectsUserString) {
      (loadUserGendersPromise || loadUserGenders(usersInThread)).then(setLabel, () => {
        // Couldn't get the gender, use the genderless version.
        setLabel(true);
      });
    } else {
      setLabel();
    }

    const firstElement = this.collapsedRange[0];
    const tagName = ['LI', 'DD'].includes(firstElement.tagName) ? firstElement.tagName : 'DIV';
    const expandNote = document.createElement(tagName);
    expandNote.className = 'cd-thread-button-container cd-thread-expandNote';
    if (firstElement.classList.contains('cd-connectToPreviousItem')) {
      expandNote.className += ' cd-connectToPreviousItem';
    }
    expandNote.appendChild(button.element);
    if (firstElement.parentNode.tagName === 'OL' && this.rootComment.ahContainerListType !== 'ol') {
      const container = document.createElement('ul');
      container.className = 'cd-commentLevel';
      container.appendChild(expandNote);
      firstElement.parentNode.parentNode.insertBefore(container, firstElement.parentNode);
      this.expandNoteContainer = container;
    } else {
      firstElement.parentNode.insertBefore(expandNote, firstElement);
    }

    /**
     * Note in place of a collapsed thread that has a button to expand the thread.
     *
     * @type {Element|undefined}
     * @private
     */
    this.expandNote = expandNote;

    /**
     * Note in place of a collapsed thread that has a button to expand the thread.
     *
     * @type {external:jQuery|undefined}
     */
    this.$expandNote = $(expandNote);
  }

  /**
   * Handle clicking the expand note.
   *
   * @param {Event} e
   * @private
   */
  onExpandNoteClick(e) {
    if (isCmdModifierPressed(e)) {
      commentRegistry.getAll().slice().reverse().forEach((comment) => {
        if (comment.thread?.isCollapsed) {
          comment.thread.expand();
        }
      });
      this.comments[0].scrollTo();
    } else {
      this.expand();
    }
  }

  /**
   * Collapse the thread.
   *
   * @param {Promise.<undefined>} [loadUserGendersPromise]
   * @param {boolean} [auto=false] Automatic collapse - don't scroll anywhere and don't save
   *   collapsed threads.
   */
  collapse(loadUserGendersPromise, auto = false) {
    if (this.isCollapsed) return;

    /**
     * Nodes that are collapsed. These can change, at least due to comment forms showing up.
     *
     * @type {Node[]|undefined}
     * @private
     */
    this.collapsedRange = getRangeContents(
      this.getAdjustedStartElement(),
      this.getAdjustedEndElement(),
      controller.rootElement
    );

    this.collapsedRange.forEach((el) => {
      // We use a class here because there can be elements in the comment that are hidden from the
      // beginning and should stay so when reshowing the comment.
      el.classList.add('cd-hidden');

      // An element can be in more than one collapsed range. So, we need to show it when expanding
      // a range only if no active collapsed ranges are left.
      const $el = $(el);
      const roots = $el.data('cd-collapsed-thread-root-comments') || [];
      roots.push(this.rootComment);
      $el.data('cd-collapsed-thread-root-comments', roots);
    });

    this.isCollapsed = true;

    for (let i = this.rootComment.index; i <= this.lastComment.index; i++) {
      i = commentRegistry.getByIndex(i).collapse(this) ?? i;
    }

    this.addExpandNode(loadUserGendersPromise);

    if (!auto) {
      this.$expandNote.cdScrollIntoView();
    }

    if (this.rootComment.isOpeningSection) {
      this.rootComment.section.actions.moreMenuSelect
        ?.getMenu()
        .findItemFromData('editOpeningComment')
        ?.setDisabled(true);
    }

    if (this.endElement !== this.visualEndElement) {
      for (let c = this.rootComment; c; c = c.getParent(true)) {
        const thread = c.thread;
        if (thread && thread.endElement !== thread.visualEndElement) {
          thread.line?.classList.remove('cd-thread-line-extended');
        }
      }
    }

    if (!auto) {
      this.constructor.saveCollapsedThreads();
    }
    this.constructor.updateLines();
    controller.handleScroll();
  }

  /**
   * Expand the thread.
   *
   * @param {boolean} [auto=false] Automatic expand - don't save collapsed threads.
   */
  expand(auto = false) {
    if (!this.isCollapsed) return;

    this.collapsedRange.forEach((el) => {
      const $el = $(el);
      const roots = $el.data('cd-collapsed-thread-root-comments') || [];
      removeFromArrayIfPresent(roots, this.rootComment);
      $el.data('cd-collapsed-thread-root-comments', roots);
      if (!roots.length && !$el.data('cd-comment-form')) {
        el.classList.remove('cd-hidden');
      }
    });

    this.expandNote.remove();
    this.expandNote = null;
    this.expandNoteContainer?.remove();
    this.expandNoteContainer = null;

    if (this.rootComment.isOpeningSection) {
      this.rootComment.section.actions.moreMenuSelect
        ?.getMenu()
        .findItemFromData('editOpeningComment')
        ?.setDisabled(false);
    }

    this.isCollapsed = false;
    let areOutdentedCommentsShown = false;
    for (let i = this.rootComment.index; i <= this.lastComment.index; i++) {
      const comment = commentRegistry.getByIndex(i);
      i = comment.expand() ?? i;
      if (comment.isOutdented) {
        areOutdentedCommentsShown = true;
      }
    }

    if (this.endElement !== this.visualEndElement && areOutdentedCommentsShown) {
      for (let c = this.rootComment; c; c = c.getParent()) {
        const thread = c.thread;
        if (thread && thread.endElement !== thread.visualEndElement) {
          thread.line?.classList.add('cd-thread-line-extended');
        }
      }
    }

    if (!auto) {
      this.constructor.saveCollapsedThreads();
    }
    this.constructor.updateLines();
    controller.handleScroll();
  }

  /**
   * Expand the thread if it's collapsed and collapse if it's expanded.
   *
   * @private
   */
  toggle() {
    if (this.isCollapsed) {
      this.expand();
    } else {
      this.collapse();
    }
  }

  /**
   * Calculate the offset of the thread line.
   *
   * @param {object} options
   * @param {Element[]} options.elementsToAdd
   * @param {Thread[]} options.threadsToUpdate
   * @param {number} options.scrollX
   * @param {number} options.scrollY
   * @param {object[]} options.floatingRects
   * @returns {boolean}
   * @private
   */
  updateLine({ elementsToAdd, threadsToUpdate, scrollX, scrollY, floatingRects }) {
    const getLeft = (rectOrOffset, commentMargins, dir) => {
      let offset;

      // This calculation is the same as in .cd-comment-overlay-marker, but without -1px - we don't
      // need it. Don't round - we need a subpixel-precise value.
      const centerOffset = -(
        (
          (cd.g.commentMarkerWidth / cd.g.pixelDeviationRatio) -
          (1 / cd.g.pixelDeviationRatioFor1px)
        )
        / 2
      );

      if (dir === 'ltr') {
        offset = rectOrOffset.left + centerOffset;
        if (commentMargins) {
          offset -= commentMargins.left + 1;
        }
      } else {
        offset = (
          rectOrOffset.right -
          (cd.g.commentMarkerWidth / cd.g.pixelDeviationRatio) -
          centerOffset
        );
        if (commentMargins) {
          offset += commentMargins.right + 1;
        }
      }
      if (rectOrOffset instanceof DOMRect) {
        offset += scrollX;
      }
      return offset - cd.g.threadLineSidePadding;
    };
    const getTop = (rectOrOffset) => (
      rectOrOffset instanceof DOMRect ?
        scrollY + rectOrOffset.top :
        rectOrOffset.top
    );

    const comment = this.rootComment;

    if (comment.isCollapsed && !this.isCollapsed) {
      this.removeLine();
      return false;
    }

    const needCalculateMargins = (
      comment.level === 0 ||
      comment.containerListType === 'ol' ||

      // Occurs when part of a comment that is not in the thread is next to the start element, for
      // example
      // https://ru.wikipedia.org/wiki/Project:Запросы_к_администраторам/Архив/2021/04#202104081533_Macuser
      // - the next comment is not in the thread.
      this.startElement.tagName === 'DIV'
    );

    const rectTop = this.isCollapsed || !needCalculateMargins ?
      this.getAdjustedStartElement().getBoundingClientRect() :
      undefined;

    const rectOrOffset = rectTop || comment.getOffset({ floatingRects });

    // Should be below comment.getOffset() as Comment#isStartStretched is set inside that call.
    const commentMargins = needCalculateMargins ? comment.getMargins() : undefined;

    let top;
    let left;
    const dir = comment.getDirection();
    if (rectOrOffset) {
      top = getTop(rectOrOffset);
      left = getLeft(rectOrOffset, commentMargins, dir);
    }

    const rectBottom = this.isCollapsed ?
      rectTop :
      this.getAdjustedEndElement(true)?.getBoundingClientRect();

    const areTopAndBottomAligned = () => {
      // FIXME: We use the first comment part's margins for the bottom rectangle which can lead to
      // errors (need to check).
      const bottomLeft = getLeft(rectBottom, commentMargins, dir);

      return dir === 'ltr' ? bottomLeft >= left : bottomLeft <= left;
    };
    if (
      top === undefined ||
      !rectBottom ||
      !getVisibilityByRects(...[rectTop, rectBottom].filter(defined)) ||
      !areTopAndBottomAligned()
    ) {
      this.removeLine();
      return false;
    }

    const height = rectBottom.bottom - (top - scrollY);

    // Find the top comment that has its offset changed and stop at it.
    if (
      this.clickAreaOffset &&
      top === this.clickAreaOffset.top &&
      left === this.clickAreaOffset.left &&
      height === this.clickAreaOffset.height
    ) {
      // Opened/closed "Reply in section" comment form will change a 0-level thread line height,
      // so we may go a long way until we finally arrive at a 0-level comment (or a comment
      // without a parent).
      return !comment.getParent();
    }

    this.clickAreaOffset = { top, left, height };

    if (!this.line) {
      this.createLine();
    }

    threadsToUpdate.push(this);
    if (!this.clickArea.parentNode) {
      elementsToAdd.push(this.clickArea);
    }

    return false;
  }

  /**
   * Set the click area offset based on the `clickAreaOffset` property.
   *
   * @private
   */
  updateClickAreaOffset() {
    this.clickArea.style.left = this.clickAreaOffset.left + 'px';
    this.clickArea.style.top = this.clickAreaOffset.top + 'px';
    this.clickArea.style.height = this.clickAreaOffset.height + 'px';
  }

  /**
   * Remove the thread line if present and set the relevant properties to `null`.
   *
   * @private
   */
  removeLine() {
    if (!this.line) return;

    this.clickArea.remove();
    this.clickArea = this.clickAreaOffset = this.line = null;
  }

  /**
   * Get all comments in the thread.
   *
   * @returns {import('./Comment').default[]}
   */
  getComments() {
    return commentRegistry.getAll().slice(this.rootComment.index, this.lastComment.index + 1);
  }

  static isInited = false;
  static navMode = false;

  /**
   * _For internal use._ Create element prototypes to reuse them instead of creating new elements
   * from scratch (which is more expensive).
   */
  static initPrototypes() {
    this.prototypes = new PrototypeRegistry();

    this.prototypes.add(
      'expandButton',
      (new OO.ui.ButtonWidget({
        // Isn't displayed
        label: 'Expand the thread',
        icon: 'expand',

        framed: false,
        classes: [
          'cd-button-ooui',
          'cd-button-expandNote',
          'cd-thread-button',
          'cd-thread-button-invisible',
          'cd-icon',
        ],
      })).$element[0]
    );

    const threadClickArea = document.createElement('div');
    threadClickArea.className = 'cd-thread-clickArea';
    const line = document.createElement('div');
    line.className = 'cd-thread-line';
    threadClickArea.appendChild(line);
    this.prototypes.add('clickArea', threadClickArea);
  }

  /**
   * Create threads. Can be re-run if DOM elements are replaced.
   *
   * @param {boolean} [autocollapse=true] Autocollapse threads according to the settings and restore
   *   collapsed threads from the local storage.
   */
  static init(autocollapse = true) {
    this.enabled = settings.get('enableThreads');
    if (!this.enabled) {
      (new StorageItem('collapsedThreads')).removeItem();
      return;
    }

    this.updateLinesHandler = this.updateLines.bind(this);

    if (!this.isInited) {
      controller
        .on('resize', this.updateLinesHandler)
        .on('mutate', () => {
          // Update only on mouse move to prevent short freezings of a page when there is a comment
          // form in the beginning of a very long page and the input is changed so that everything
          // below the form shifts vertically.
          $(document)
            .off('mousemove.cd', this.updateLinesHandler)
            .one('mousemove.cd', this.updateLinesHandler);
        });
      $(document).on('visibilitychange', this.updateLinesHandler);
      updateChecker
        // Start and end elements of threads may be replaced, so we need to restart threads.
        .on('newChanges', this.init.bind(this, false));
    }

    this.collapseThreadsLevel = settings.get('collapseThreadsLevel');
    this.treeWalker = new ElementsTreeWalker(undefined, controller.rootElement);
    commentRegistry.getAll().forEach((rootComment) => {
      try {
        rootComment.thread?.expand(true);
        rootComment.thread = new Thread(rootComment);
      } catch {
        // Empty
      }
    });

    if (!this.threadLinesContainer) {
      this.threadLinesContainer = document.createElement('div');
      this.threadLinesContainer.className = 'cd-threadLinesContainer';
    } else {
      this.threadLinesContainer.innerHTML = '';
    }

    // We could choose not to update lines on initialization as it is a relatively costly operation
    // that can be delayed, but not sure it makes any difference at which point the page is blocked
    // for interactions.
    this.updateLines();

    if (!this.threadLinesContainer.parentNode) {
      document.body.appendChild(this.threadLinesContainer);
    }
    if (autocollapse) {
      this.autocollapseThreads();
    }
    this.isInited = true;
  }

  /**
   * Autocollapse threads starting from some level according to the setting value and restore
   * collapsed threads from the local storage.
   *
   * @private
   */
  static autocollapseThreads() {
    const collapsedThreadsStorageItem = (new StorageItem('collapsedThreads'))
      .cleanUp((entry) => (
        !(entry.collapsedThreads || entry.threads)?.length ||
        // FIXME: Remove `([keep] || entry.saveUnixTime)` after June 2024
        (entry.saveTime || entry.saveUnixTime) < Date.now() - 60 * cd.g.msInDay
      ));
    const data = collapsedThreadsStorageItem.get(mw.config.get('wgArticleId')) || {};

    const comments = [];

    // FIXME: Leave only data.collapsedThreads after June 2024
    (data.collapsedThreads || data.threads)?.forEach((thread) => {
      const comment = commentRegistry.getById(thread.id);
      if (comment?.thread) {
        if (thread.collapsed) {
          comments.push(comment);
        } else {
          /**
           * Whether the thread should have been autocollapsed, but haven't been because the user
           * expanded it manually in previous sessions.
           *
           * @name wasManuallyExpanded
           * @type {boolean}
           * @memberof Thread
           * @instance
           * @private
           */
          comment.thread.wasManuallyExpanded = true;
        }
      } else {
        // Remove IDs that have no corresponding comments or threads from the data. FIXME: Leave
        // only data.collapsedThreads after June 2024
        removeFromArrayIfPresent(data.collapsedThreads || data.threads, thread);
      }
    });

    if (this.collapseThreadsLevel !== 0) {
      // Don't precisely target comments of level this.collapseThreadsLevel in case there is a gap,
      // for example between the `(this.collapseThreadsLevel - 1)` level and the
      // `(this.collapseThreadsLevel + 1)` level (the user muse have replied to a comment at the
      // `(this.collapseThreadsLevel - 1)` level but inserted `::` instead of `:`).
      for (let i = 0; i < commentRegistry.getCount(); i++) {
        const comment = commentRegistry.getByIndex(i);
        if (!comment.thread) continue;

        if (comment.level >= this.collapseThreadsLevel) {
          // Exclude threads where the user participates at any level up and down the tree or that
          // the user has specifically expanded.
          if (![...comment.getAncestors(), ...comment.thread.comments].some((c) => c.isOwn)) {
            /**
             * Should the thread be automatically collapsed on page load if taking only comment
             * level into account and not remembering the user's previous actions.
             *
             * @name isAutocollapseTarget
             * @type {boolean}
             * @memberof Thread
             * @instance
             * @private
             */
            comment.thread.isAutocollapseTarget = true;

            if (!comment.thread.wasManuallyExpanded) {
              comments.push(comment);
            }
          }

          i = comment.thread.lastComment.index;
        }
      }
    }

    const loadUserGendersPromise = cd.g.genderAffectsUserString ?
      loadUserGenders(comments.flatMap((comment) => comment.thread.getUsers())) :
      undefined;

    // The reverse order is used for threads to be expanded correctly.
    comments
      .sort((c1, c2) => c1.index - c2.index)
      .forEach((comment) => {
        comment.thread.collapse(loadUserGendersPromise, true);
      });

    if (controller.isCurrentRevision()) {
      collapsedThreadsStorageItem
        .setWithTime(mw.config.get('wgArticleId'), data.collapsedThreads)
        .save();
    }
  }

  /**
   * Find the closest item element (`<li>`, `<dd>`) for an element.
   *
   * @param {Element} element
   * @param {number} level
   * @param {Element} nextForeignElement
   * @returns {?Element}
   * @private
   */
  static findItemElement(element, level, nextForeignElement) {
    this.treeWalker.currentNode = element;

    let item;
    let previousNode = element;
    do {
      if (this.treeWalker.currentNode.classList.contains('cd-commentLevel')) {
        const className = this.treeWalker.currentNode.getAttribute('class');
        const match = className.match(/cd-commentLevel-(\d+)/);
        if (match && Number(match[1]) === (level || 1)) {
          // If the level is 0 (outdented comment or subitem of a 0-level comment), we need the list
          // element, not the item element.
          item = level === 0 ? this.treeWalker.currentNode : previousNode;

          // The element can contain parts of a comment that is not in the thread, for example
          // https://ru.wikipedia.org/wiki/Википедия:К_оценке_источников#202104120830_RosssW_2.
          if (nextForeignElement && item.contains(nextForeignElement)) {
            return null;
          }

          break;
        }
      }
      previousNode = this.treeWalker.currentNode;
    } while (this.treeWalker.parentNode());

    return item || null;
  }

  /**
   * Find the thread's end element for a comment at the 0th level.
   *
   * @param {Element} startElement
   * @param {Element[]} highlightables
   * @param {Element} nextForeignElement
   * @returns {Element}
   * @private
   */
  static findEndElement(startElement, highlightables, nextForeignElement) {
    let commonAncestor = startElement;
    const lastHighlightable = highlightables[highlightables.length - 1];
    do {
      commonAncestor = commonAncestor.parentNode;
    } while (!commonAncestor.contains(lastHighlightable));

    let endElement = lastHighlightable;
    for (
      let n = endElement.parentNode;
      n !== commonAncestor && !(nextForeignElement && n.contains(nextForeignElement));
      n = n.parentNode
    ) {
      endElement = n;
    }

    // "Reply in section", "There are new comments in this thread" button container
    for (
      let n = endElement.nextElementSibling;
      n && n.tagName === 'DL' && n.classList.contains('cd-section-button-container');
      n = n.nextElementSibling
    ) {
      endElement = n;
    }

    return endElement;
  }

  /**
   * _For internal use._ Calculate the offset and (if needed) add the thread lines to the container.
   */
  static updateLines() {
    if (!this.enabled || document.hidden) return;

    const elementsToAdd = [];
    const threadsToUpdate = [];
    const scrollX = window.scrollX;
    const scrollY = window.scrollY;

    const floatingRects = controller.getFloatingElements().map(getExtendedRect);
    commentRegistry.getAll()
      .slice()
      .reverse()
      .some((comment) => (
        comment.thread?.updateLine({
          elementsToAdd,
          threadsToUpdate,
          scrollX,
          scrollY,
          floatingRects,
        }) ||
        false
      ));

    // Faster to update/add all elements in one batch.
    threadsToUpdate.forEach((thread) => {
      thread.updateClickAreaOffset();
    });

    if (elementsToAdd.length) {
      this.threadLinesContainer.append(...elementsToAdd);
    }
  }

  /**
   * Save collapsed threads to the local storage.
   *
   * @private
   */
  static saveCollapsedThreads() {
    if (!controller.isCurrentRevision()) return;

    (new StorageItem('collapsedThreads'))
      .setWithTime(
        mw.config.get('wgArticleId'),
        commentRegistry
          .query((comment) => (
            comment.thread &&
            comment.thread.isCollapsed !== Boolean(comment.thread.isAutocollapseTarget)
          ))
          .map((comment) => ({
            id: comment.id,
            collapsed: comment.thread.isCollapsed,
          }))
      )
      .save();
  }
}

export default Thread;