src/navPanel.js

/**
 * Singleton related to the navigation panel. It also contains new comments-related functions and
 * configuration.
 *
 * @module navPanel
 */

import Button from './Button';
import LiveTimestamp from './LiveTimestamp';
import cd from './cd';
import commentFormRegistry from './commentFormRegistry';
import commentRegistry from './commentRegistry';
import controller from './controller';
import settings from './settings';
import { reorderArray } from './utils-general';
import { formatDate } from './utils-timestamp';
import { removeWikiMarkup } from './utils-wikitext';
import { isCmdModifierPressed, isInputFocused, keyCombination } from './utils-window';
import visits from './visits';

export default {
  /**
   * _For internal use._ Mount, unmount or reset the navigation panel based on the context.
   */
  setup() {
    this.timestampFormat = settings.get('timestampFormat');
    this.modifyToc = settings.get('modifyToc');
    this.highlightNewInterval = settings.get('highlightNewInterval');

    if (cd.page.isActive()) {
      // Can be mounted not only on first parse, if using RevisionSlider, for example.
      if (!this.isMounted()) {
        this.mount();
        controller
          .on('scroll', this.updateCommentFormButton.bind(this))
          .on('keydown', (e) => {
            if (isInputFocused()) return;

            // R
            if (keyCombination(e, 82)) {
              this.refreshClick();
            }

            // W
            if (keyCombination(e, 87)) {
              this.goToPreviousNewComment();
            }

            // S
            if (keyCombination(e, 83)) {
              this.goToNextNewComment();
            }

            // F
            if (keyCombination(e, 70)) {
              this.goToFirstUnseenComment();
            }

            // C
            if (keyCombination(e, 67)) {
              e.preventDefault();
              this.goToNextCommentForm(true);
            }
          })
          .on('addedCommentsUpdate', ({ all, relevant, bySection }) => {
            this.updateRefreshButton(all.length, bySection, Boolean(relevant.length));
          });
        commentFormRegistry
          .on('add', this.updateCommentFormButton.bind(this))
          .on('remove', this.updateCommentFormButton.bind(this));
        LiveTimestamp
          .on('updateimproved', this.updateTimestampsInRefreshButtonTooltip.bind(this));
        visits
          .on('process', this.fill.bind(this));
        commentRegistry
          .on('registerSeen', this.updateFirstUnseenButton.bind(this));
      } else {
        this.reset();
      }
    } else {
      if (this.isMounted()) {
        this.unmount();
      }
    }
  },

  /**
   * Render the navigation panel. This is done when the page is first loaded, or created using the
   * script.
   *
   * @private
   */
  mount() {
    /**
     * Navigation panel element.
     *
     * @name $element
     * @type {external:jQuery}
     * @memberof module:navPanel
     */
    this.$element = $('<div>')
      .attr('id', 'cd-navPanel')
      .appendTo(document.body);

    /**
     * Refresh button.
     *
     * @name refreshButton
     * @type {Button|undefined}
     * @memberof module:navPanel
     * @private
     */
    this.refreshButton = new Button({
      tagName: 'div',
      classes: ['cd-navPanel-button'],
      id: 'cd-navPanel-refreshButton',
      action: (e) => {
        this.refreshClick(isCmdModifierPressed(e));
      },
    });

    this.updateRefreshButtonTooltip(0);

    /**
     * "Go to the previous new comment" button element.
     *
     * @name previousButton
     * @type {Button|undefined}
     * @memberof module:navPanel
     * @private
     */
    this.previousButton = new Button({
      tagName: 'div',
      classes: ['cd-navPanel-button'],
      id: 'cd-navPanel-previousButton',
      tooltip: `${cd.s('navpanel-previous')} ${cd.mws('parentheses', 'W')}`,
      action: () => {
        this.goToPreviousNewComment();
      },
    }).hide();

    /**
     * "Go to the next new comment" button element.
     *
     * @name nextButton
     * @type {Button|undefined}
     * @memberof module:navPanel
     * @private
     */
    this.nextButton = new Button({
      tagName: 'div',
      classes: ['cd-navPanel-button'],
      id: 'cd-navPanel-nextButton',
      tooltip: `${cd.s('navpanel-next')} ${cd.mws('parentheses', 'S')}`,
      action: () => {
        this.goToNextNewComment();
      },
    }).hide();

    /**
     * "Go to the first unseen comment" button element.
     *
     * @name firstUnseenButton
     * @type {Button|undefined}
     * @memberof module:navPanel
     * @private
     */
    this.firstUnseenButton = new Button({
      tagName: 'div',
      classes: ['cd-navPanel-button'],
      id: 'cd-navPanel-firstUnseenButton',
      tooltip: `${cd.s('navpanel-firstunseen')} ${cd.mws('parentheses', 'F')}`,
      action: () => {
        this.goToFirstUnseenComment();
      },
    }).hide();

    /**
     * "Go to the next comment form out of sight" button element.
     *
     * @name commentFormButton
     * @type {Button|undefined}
     * @memberof module:navPanel
     * @private
     */
    this.commentFormButton = new Button({
      tagName: 'div',
      classes: ['cd-navPanel-button'],
      id: 'cd-navPanel-commentFormButton',
      tooltip: `${cd.s('navpanel-commentform')} ${cd.mws('parentheses', 'C')}`,
      action: () => {
        this.goToNextCommentForm();
      },
    }).hide();

    this.$element.append(
      this.refreshButton.element,
      this.previousButton.element,
      this.nextButton.element,
      this.firstUnseenButton.element,
      this.commentFormButton.element,
    );
  },

  /**
   * Remove the navigation panel.
   *
   * @private
   */
  unmount() {
    this.$element.remove();
    this.$element = null;
  },

  /**
   * Check if the navigation panel is mounted. Is equivalent to checking the existence of
   * {@link module:navPanel.$element}, and for most practical purposes, does the same as the
   * {@link module:Page#isActive} check.
   *
   * @returns {boolean}
   */
  isMounted() {
    return Boolean(this.$element);
  },

  /**
   * Reset the navigation panel to the initial state. This is done after page refreshes. (Comment
   * forms are expected to be restored already.)
   *
   * @private
   */
  reset() {
    this.refreshButton.setLabel('');
    this.updateRefreshButtonTooltip(0);
    this.previousButton.hide();
    this.nextButton.hide();
    this.firstUnseenButton.hide();
    this.commentFormButton.hide();
    clearTimeout(this.utirbtTimeout);
  },

  /**
   * Count the new and unseen comments on the page and update the navigation panel to reflect that.
   *
   * @private
   */
  fill() {
    if (commentRegistry.getAll().some((comment) => comment.isNew)) {
      this.updateRefreshButtonTooltip(0);
      this.previousButton.show();
      this.nextButton.show();
      this.updateFirstUnseenButton();
    }
  },

  /**
   * Perform routines at the refresh button click.
   *
   * @param {boolean} markAsRead Whether to mark all comments as read.
   * @private
   */
  refreshClick(markAsRead) {
    // There was reload confirmation here, but after session restore was introduced, the
    // confirmation seems to be no longer needed.
    controller.reload({
      commentIds: controller.getRelevantAddedCommentIds(),
      markAsRead,
    });
  },

  /**
   * Generic function for {@link module:navPanel.goToPreviousNewComment} and
   * {@link module:navPanel.goToNextNewComment}.
   *
   * @param {string} direction
   * @private
   */
  goToNewCommentInDirection(direction) {
    if (controller.isAutoScrolling()) return;

    const commentInViewport = commentRegistry.findInViewport(direction);
    if (!commentInViewport) return;

    const reorderedComments = reorderArray(
      commentRegistry.getAll(),
      commentInViewport.index,
      direction === 'backward'
    );
    const candidates = reorderedComments
      .filter((comment) => comment.isNew && !comment.isInViewport());
    const comment = candidates.find((comment) => comment.isInViewport() === false) || candidates[0];
    if (comment) {
      comment.scrollTo({
        flash: null,
        callback: () => {
          // The default `controller.handleScroll()` callback is executed in `$#cdScrollTo`, but
          // that happens after a 300ms timeout, so we have a chance to have our callback executed
          // first.
          comment.registerSeen(direction, true);
        },
      });
    }
  },

  /**
   * Scroll to the previous new comment.
   */
  goToPreviousNewComment() {
    this.goToNewCommentInDirection('backward');
  },

  /**
   * Scroll to the next new comment.
   */
  goToNextNewComment() {
    this.goToNewCommentInDirection('forward');
  },

  /**
   * Scroll to the first unseen comment.
   */
  goToFirstUnseenComment() {
    if (controller.isAutoScrolling()) return;

    const candidates = commentRegistry.query((comment) => comment.isSeen === false);
    const comment = candidates.find((comment) => comment.isInViewport() === false) || candidates[0];
    comment?.scrollTo({
      flash: null,
      callback: () => {
        // The default `controller.handleScroll()` callback is executed in `$#cdScrollTo`, but
        // that happens after a 300ms timeout, so we have a chance to have our callback executed
        // first.
        comment.registerSeen('forward', true);
      },
    });
  },

  /**
   * Go to the next comment form out of sight, or just the next comment form, if `inSight` is set to
   * true.
   *
   * @param {boolean} [inSight=false]
   */
  goToNextCommentForm(inSight) {
    commentFormRegistry
      .query((commentForm) => inSight || !commentForm.$element.cdIsInViewport(true))
      .map((commentForm) => {
        let top = commentForm.$element[0].getBoundingClientRect().top;
        if (top < 0) {
          top += $(document).height() * 2;
        }
        return { commentForm, top };
      })
      .sort((data1, data2) => data1.top - data2.top)
      .map((data) => data.commentForm)[0]
      ?.goTo();
  },

  /**
   * _For internal use._ Update the refresh button to show the number of comments added to the page
   * since it was loaded.
   *
   * @param {number} commentCount
   * @param {Map} commentsBySection
   * @param {boolean} areThereRelevant
   * @private
   */
  updateRefreshButton(commentCount, commentsBySection, areThereRelevant) {
    this.refreshButton.setLabel('');
    this.updateRefreshButtonTooltip(commentCount, commentsBySection);
    if (commentCount) {
      $('<span>')
        // Can't set the attribute to the button as its tooltip may have another direction.
        .attr('dir', 'ltr')

        .text(`+${commentCount}`)
        .appendTo(this.refreshButton.element);
    }
    this.refreshButton.element.classList
      .toggle('cd-navPanel-refreshButton-relevant', areThereRelevant);
  },

  /**
   * Update the tooltip of the refresh button, displaying statistics of comments not yet displayed
   * if there are such.
   *
   * @param {number} commentCount
   * @param {Map} [commentsBySection]
   * @private
   */
  updateRefreshButtonTooltip(commentCount, commentsBySection) {
    // If the method was not called after a timeout and the timeout exists, clear it.
    clearTimeout(this.utirbtTimeout);

    this.cachedCommentCount = commentCount;
    this.cachedCommentsBySection = commentsBySection;

    let tooltipText = null;
    const areThereNew = commentRegistry.getAll().some((comment) => comment.isNew);
    if (commentCount) {
      tooltipText = (
        cd.s('navpanel-newcomments-count', commentCount) +
        ' ' +
        cd.s('navpanel-newcomments-refresh') +
        ' ' +
        cd.mws('parentheses', 'R')
      );
      if (areThereNew && this.highlightNewInterval) {
        tooltipText += '\n' + cd.s('navpanel-markasread', cd.g.cmdModifier);
      }
      const bullet = removeWikiMarkup(cd.s('bullet'));
      const rtlMarkOrNot = cd.g.contentDirection === 'rtl' ? '\u200f' : '';
      commentsBySection.forEach((comments, section) => {
        const headline = section?.headline;
        tooltipText += headline ? `\n\n${headline}` : '\n';
        comments.forEach((comment) => {
          tooltipText += `\n`;
          const names = comment.parent?.author && comment.level > 1 ?
            cd.s(
              'navpanel-newcomments-names',
              comment.author.getName(),
              comment.parent.author.getName()
            ) :
            comment.author.getName();
          const date = comment.date ?
            formatDate(comment.date) :
            cd.s('navpanel-newcomments-unknowndate');
          tooltipText += bullet + ' ' + names + rtlMarkOrNot + cd.mws('comma-separator') + date;
        });
      });

      // When timestamps are relative, we need to update the tooltip manually every minute. When
      // `improved` timestamps are used, timestamps are updated in `LiveTimestamp.updateImproved()`.
      if (this.timestampFormat === 'relative') {
        this.utirbtTimeout = setTimeout(
          this.updateTimestampsInRefreshButtonTooltip.bind(this),
          cd.g.msInMin
        );
      }
    } else {
      tooltipText = cd.s('navpanel-refresh') + ' ' + cd.mws('parentheses', 'R');
      if (areThereNew && this.highlightNewInterval) {
        tooltipText += '\n' + cd.s('navpanel-markasread', cd.g.cmdModifier);
      }
    }

    this.refreshButton.setTooltip(tooltipText);
  },

  /**
   * Update the tooltip of the {@link module:navPanel.refreshButton refresh button}. This is called
   * to update timestamps in the text.
   *
   * @private
   */
  updateTimestampsInRefreshButtonTooltip() {
    this.updateRefreshButtonTooltip(this.cachedCommentCount, this.cachedCommentsBySection);
  },

  /**
   * Update the state of the
   * {@link module:navPanel.firstUnseenButton "Go to the first unseen comment"} button.
   *
   * @private
   */
  updateFirstUnseenButton() {
    if (!this.isMounted()) return;

    const unseenCommentCount = commentRegistry.query((c) => c.isSeen === false).length;
    this.firstUnseenButton.toggle(unseenCommentCount).setLabel(unseenCommentCount);
  },

  /**
   * Update the {@link module:navPanel.commentFormButton "Go to the next comment form out of sight"}
   * button visibility.
   *
   * @private
   */
  updateCommentFormButton() {
    if (!this.isMounted() || controller.isAutoScrolling()) return;

    this.commentFormButton.toggle(
      commentFormRegistry.getAll().some((cf) => !cf.$element.cdIsInViewport(true))
    );
  },
};