Comments Owl for Hacker News

Highlight new comments, mute users, and other tweaks for Hacker News

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Comments Owl for Hacker News
// @description Highlight new comments, mute users, and other tweaks for Hacker News
// @namespace   https://github.com/insin/comments-owl-for-hacker-news/
// @match       https://news.ycombinator.com/*
// @version     48
// ==/UserScript==
let debug = false
let isSafari = navigator.userAgent.includes('Safari/') && !/Chrom(e|ium)\//.test(navigator.userAgent)

const HIGHLIGHT_COLOR = '#ffffde'
const TOGGLE_HIDE = '[–]'
const TOGGLE_SHOW = '[+]'
const MUTED_USERS_KEY = 'mutedUsers'
const USER_NOTES_KEY = 'userNotes'
const LOGGED_OUT_USER_PAGE = `<head>
  <meta name="referrer" content="origin">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" type="text/css" href="news.css">
  <link rel="shortcut icon" href="favicon.ico">
  <title>Muted | Comments Owl for Hacker News</title>
</head>
<body>
  <center>
    <table id="hnmain" width="85%" cellspacing="0" cellpadding="0" border="0" bgcolor="#f6f6ef">
      <tbody>
        <tr>
          <td bgcolor="#ff6600">
            <table style="padding: 2px" width="100%" cellspacing="0" cellpadding="0" border="0">
              <tbody>
                <tr>
                  <td style="width: 18px; padding-right: 4px">
                    <a href="https://news.ycombinator.com">
                      <img src="y18.svg" style="border: 1px white solid; display: block" width="18" height="18">
                    </a>
                  </td>
                  <td style="line-height: 12pt; height: 10px">
                    <span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
                      <a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a>
                    </span>
                  </td>
                  <td style="text-align: right; padding-right: 4px">
                    <span class="pagetop">
                      <a href="login?goto=news">login</a>
                    </span>
                  </td>
                </tr>
              </tbody>
            </table>
          </td>
        </tr>
        <tr id="pagespace" title="Muted" style="height: 10px"></tr>
        <tr>
          <td>
            <table border="0">
              <tbody>
                <tr class="athing">
                  <td valign="top">user:</td>
                  <td>
                    <a class="hnuser">anonymous comments owl user</a>
                  </td>
                </tr>
              </tbody>
            </table>
            <br><br>
          </td>
        </tr>
      </tbody>
    </table>
  </center>
</body>`

//#region Config
/** @type {import("./types").Config} */
let config = {
  addUpvotedToHeader: true,
  autoCollapseNotNew: true,
  autoHighlightNew: true,
  hideCommentsNav: false,
  hideJobsNav: false,
  hidePastNav: false,
  hideReplyLinks: false,
  hideSubmitNav: false,
  listPageFlagging: 'enabled',
  listPageHiding: 'enabled',
  makeSubmissionTextReadable: true,
}
//#endregion

//#region Storage
class Visit {
  constructor({commentCount, maxCommentId, time}) {
    /** @type {number} */
    this.commentCount = commentCount
    /** @type {number} */
    this.maxCommentId = maxCommentId
    /** @type {Date} */
    this.time = time
  }

  toJSON() {
    return {
      c: this.commentCount,
      m: this.maxCommentId,
      t: this.time.getTime(),
    }
  }
}

Visit.fromJSON = function(obj) {
  return new Visit({
    commentCount: obj.c,
    maxCommentId: obj.m,
    time: new Date(obj.t),
  })
}

function getLastVisit(itemId) {
  let json = localStorage.getItem(itemId)
  if (json == null) return null
  return Visit.fromJSON(JSON.parse(json))
}

function storeVisit(itemId, visit) {
  log('storing visit', visit)
  localStorage.setItem(itemId, JSON.stringify(visit))
}

/** @returns {Set<string>} */
function getMutedUsers(json = localStorage[MUTED_USERS_KEY]) {
  return new Set(JSON.parse(json || '[]'))
}

/** @returns {Record<string, string>} */
function getUserNotes(json = localStorage[USER_NOTES_KEY]) {
  return JSON.parse(json || '{}')
}

function storeMutedUsers(mutedUsers) {
  localStorage[MUTED_USERS_KEY] = JSON.stringify(Array.from(mutedUsers))
}

function storeUserNotes(userNotes) {
  localStorage[USER_NOTES_KEY] = JSON.stringify(userNotes)
}
//#endregion

//#region Utility functions
/**
 * @param {string} role
 * @param {...string} css
 */
function addStyle(role, ...css) {
  let $style = document.createElement('style')
  $style.dataset.insertedBy = 'comments-owl'
  $style.dataset.role = role
  if (css.length > 0) {
    $style.textContent = css.filter(Boolean).map(dedent).join('\n')
  }
  document.querySelector('head').appendChild($style)
  return $style
}

const autosizeTextArea = (() => {
  /** @type {Number} */
  let textAreaPadding

  return function autosizeTextarea($textArea) {
    if (textAreaPadding == null) {
      textAreaPadding = Number(getComputedStyle($textArea).paddingTop.replace('px', '')) * 2
    }
    $textArea.style.height = '0px'
    $textArea.style.height = $textArea.scrollHeight + textAreaPadding + 'px'
  }
})()

function checkbox(attributes, label) {
  return h('label', null,
    h('input', {
      style: {verticalAlign: 'middle'},
      type: 'checkbox',
      ...attributes,
    }),
    ' ',
    label,
  )
}

/**
 * @param {string} str
 * @return {string}
 */
function dedent(str) {
  str = str.replace(/^[ \t]*\r?\n/, '')
  let indent = /^[ \t]+/m.exec(str)
  if (indent) str = str.replace(new RegExp('^' + indent[0], 'gm'), '')
  return str.replace(/(\r?\n)[ \t]+$/, '$1')
}

/**
 * Create an element.
 * @param {string} tagName
 * @param {{[key: string]: any}} [attributes]
 * @param {...any} children
 * @returns {HTMLElement}
 */
function h(tagName, attributes, ...children) {
  let $el = document.createElement(tagName)

  if (attributes) {
    for (let [prop, value] of Object.entries(attributes)) {
      if (prop.indexOf('on') === 0) {
        $el.addEventListener(prop.slice(2).toLowerCase(), value)
      }
      else if (prop.toLowerCase() == 'style') {
        for (let [styleProp, styleValue] of Object.entries(value)) {
          $el.style[styleProp] = styleValue
        }
      }
      else {
        $el[prop] = value
      }
    }
  }

  for (let child of children) {
    if (child == null || child === false) {
      continue
    }
    if (child instanceof Node) {
      $el.appendChild(child)
    }
    else {
      $el.insertAdjacentText('beforeend', String(child))
    }
  }

  return $el
}

function log(...args) {
  if (debug) {
    console.log('🦉', ...args)
  }
}

function warn(...args) {
  if (debug) {
    console.log('❗', ...args)
  }
}

/**
 * @param {number} count
 * @param {string} suffixes
 * @returns {string}
 */
function s(count, suffixes = ',s') {
  if (!suffixes.includes(',')) {
    suffixes = `,${suffixes}`
  }
  return suffixes.split(',')[count === 1 ? 0 : 1]
}

/**
 * @param {HTMLElement} $el
 * @param {boolean} hidden
 */
function toggleDisplay($el, hidden) {
  $el.classList.toggle('noshow', hidden)
  // We need to enforce display setting as the page's own script expands all
  // comments on page load.
  $el.style.display = hidden ? 'none' : ''
}

/**
 * @param {HTMLElement} $el
 * @param {boolean} hidden
 */
function toggleVisibility($el, hidden) {
  $el.classList.toggle('nosee', hidden)
  // We need to enforce visibility setting as the page's own script expands
  // all comments on page load.
  $el.style.visibility = hidden ? 'hidden' : 'visible'
}
//#endregion

//#region Navigation
function tweakNav() {
  let $pageTop = document.querySelector('span.pagetop')
  if (!$pageTop) {
    warn('pagetop not found')
    return
  }

  //#region CSS
  addStyle('nav-static', `
    .desktopnav {
      display: inline;
    }
    .mobilenav {
      display: none;
    }
    @media only screen and (min-width : 300px) and (max-width : 750px) {
      .desktopnav {
        display: none;
      }
      .mobilenav {
        display: revert;
      }
    }
  `)

  let $style = addStyle('nav-dynamic')

  function configureCss() {
    let hideNavSelectors = [
      config.hidePastNav && 'span.past-sep, span.past-sep + a',
      config.hideCommentsNav && 'span.comments-sep, span.comments-sep + a',
      config.hideJobsNav && 'span.jobs-sep, span.jobs-sep + a',
      config.hideSubmitNav && 'span.submit-sep, span.submit-sep + a',
      !config.addUpvotedToHeader && 'span.upvoted-sep, span.upvoted-sep + a',
    ].filter(Boolean)
    $style.textContent = hideNavSelectors.length == 0 ? '' : dedent(`
      ${hideNavSelectors.join(',\n')} {
        display: none;
      }
    `)
  }
  //#endregion

  //#region Main
  // Add a 'muted' link next to 'login' for logged-out users
  let $loginLink = document.querySelector('span.pagetop a[href^="login"]')
  if ($loginLink) {
    $loginLink.parentElement.append(
      h('a', {href: `muted`}, 'muted'),
      ' | ',
      $loginLink,
    )
  }

  // Add /upvoted if we're not on it and the user is logged in
  if (!location.pathname.startsWith('/upvoted')) {
    let $userLink = document.querySelector('span.pagetop a[href^="user?id"]')
    if ($userLink) {
      let $submit = $pageTop.querySelector('a[href="submit"]')
      $submit.insertAdjacentElement('afterend', h('a', {href: `upvoted?id=${$userLink.textContent}`}, 'upvoted'))
      $submit.insertAdjacentElement('afterend', h('span', {className: 'upvoted-sep'}, ' | '))
    }
  }

  // Wrap separators in elements so they can be used to hide items
  Array.from($pageTop.childNodes)
       .filter(n => n.nodeType == Node.TEXT_NODE && n.nodeValue == ' | ')
       .forEach(n => n.replaceWith(h('span', {className: `${n.nextSibling?.textContent}-sep`}, ' | ')))

  // Create a new row for mobile nav
  let $mobileNav = /** @type {HTMLTableCellElement} */ ($pageTop.parentElement.cloneNode(true))
  $mobileNav.querySelector('b')?.remove()
  $mobileNav.colSpan = 3
  $pageTop.closest('tbody').append(h('tr', {className: 'mobilenav'}, $mobileNav))

  // Move everything after b.hnname into a desktop nav wrapper
  $pageTop.appendChild(h('span', {className: 'desktopnav'}, ...Array.from($pageTop.childNodes).slice(1)))

  configureCss()

  chrome.storage.local.onChanged.addListener((changes) => {
    for (let [configProp, change] of Object.entries(changes)) {
      if (['hidePastNav', 'hideCommentsNav', 'hideJobsNav', 'hideSubmitNav', 'addUpvotedToHeader'].includes(configProp)) {
        config[configProp] = change.newValue
        configureCss()
      }
    }
  })
  //#endregion
}
//#endregion

//#region Comment page
/**
 * Each comment on a comment page has the following structure:
 *
 * ```html
 * <tr class="athing"> (wrapper)
 *   <td>
 *     <table>
 *       <tr>
 *         <td class="ind">
 *           <img src="s.gif" height="1" width="123"> (indentation)
 *         </td>
 *         <td class="votelinks">…</td> (vote up/down controls)
 *         <td class="default">
 *           <div style="margin-top:2px; margin-bottom:-10px;">
 *             <div class="comhead"> (meta bar: user, age and folding control)
 *             …
 *             <div class="comment">
 *             <span class="comtext"> (text and reply link)
 * ```
 *
 * We want to be able to collapse comment trees which don't contain new comments
 * and highlight new comments, so for each wrapper we'll create a `HNComment`
 * object to manage this.
 *
 * Comments are rendered as a flat list of table rows, so we'll use the width of
 * the indentation spacer to determine which comments are descendants of a given
 * comment.
 *
 * Since we have to reimplement our own comment folding, we'll hide the built-in
 * folding controls and create new ones in a better position (on the left), with
 * a larger hitbox (larger font and an en dash [–] instead of a hyphen [-]).
 *
 * On each comment page view, we store the current comment count, the max
 * comment id on the page and the current time as the last visit time.
 */
function commentPage() {
  log('comment page')

  //#region CSS
  addStyle('comments-static', `
    /* Hide default toggle and nav links */
    a.togg {
      display: none;
    }
    .toggle {
      cursor: pointer;
      margin-right: 3px;
      background: transparent;
      border: 0;
      padding: 0;
      color: inherit;
      font-family: inherit;
    }
    /* Display the mute control on hover, unless the comment is collapsed */
    .mute {
      display: none;
    }
    /* Prevent :hover causing double-tap on comment functionality in iOS Safari */
    @media(hover: hover) and (pointer: fine) {
      tr.comtr:hover td.votelinks:not(.nosee) + td .mute {
        display: inline;
      }
    }
    /* Don't show notes on collapsed comments */
    td.votelinks.nosee + td .note {
      display: none;
    }
    #timeTravel {
      margin-top: 1em;
      vertical-align: middle;
    }
    #timeTravelRange {
      width: 100%;
    }
    #timeTravelButton {
      margin-right: 1em;
    }

    @media only screen and (min-width: 300px) and (max-width: 750px) {
      td.votelinks:not(.nosee) + td .mute {
        display: inline;
      }
      /* Allow comments to go full-width */
      .comment {
        max-width: unset;
      }
      /* Increase distance between upvote and downvote */
      a[id^="down_"] {
        margin-top: 16px;
      }
      /* Increase hit-target */
      .toggle {
        font-size: 14px;
      }
      #highlightControls label {
        display: block;
      }
      #highlightControls label + label {
        margin-top: .5rem;
      }
      #timeTravelRange {
        width: calc(100% - 32px);
      }
    }
  `)

  let $style = addStyle('comments-dynamic')

  function configureCss() {
    $style.textContent = [
      config.hideReplyLinks && `
        div.reply {
          margin-top: 8px;
        }
        div.reply p {
          display: none;
        }
      `,
      config.makeSubmissionTextReadable && `
        div.toptext {
          color: #000;
        }
      `,
    ].filter(Boolean).map(dedent).join('\n')
  }
  //#endregion

  //#region Variables
  /** @type {boolean} */
  let autoCollapseNotNew = config.autoCollapseNotNew || location.search.includes('?shownew')

  /** @type {boolean} */
  let autoHighlightNew = config.autoHighlightNew || location.search.includes('?shownew')

  /** @type {HNComment[]} */
  let comments = []

  /** @type {Record<string, HNComment>} */
  let commentsById = {}

  /** @type {boolean} */
  let hasNewComments = false

  /** @type {string} */
  let itemId = /id=(\d+)/.exec(location.search)[1]

  /** @type {Visit} */
  let lastVisit

  /** @type {number} */
  let maxCommentId

  /** @type {Set<string>} */
  let mutedUsers = getMutedUsers()

  /** @type {Record<string, string>} */
  let userNotes = getUserNotes()

  // Comment counts
  let commentCount = 0
  let mutedCommentCount = 0
  let newCommentCount = 0
  let replyToMutedCommentCount = 0
  //#endregion

  class HNComment {
    /**
     * returns {boolean}
     */
    get isMuted() {
      return mutedUsers.has(this.user)
    }

    /**
     * @returns {HNComment[]}
     */
    get childComments() {
      if (this._childComments == null) {
        this._childComments = []
        for (let i = this.index + 1; i < comments.length; i++) {
          if (comments[i].indent <= this.indent) {
            break
          }
          this._childComments.push(comments[i])
        }
      }
      return this._childComments
    }

    get collapsedChildrenText() {
      return this.childCommentCount == 0 ? '' : [
        this.isDeleted ? '(' : ' | (',
        this.childCommentCount,
        ` child${s(this.childCommentCount, 'ren')})`,
      ].join('')
    }

    /**
     * @returns {HNComment[]}
     */
    get nonMutedChildComments() {
      if (this._nonMutedChildComments == null) {
        let muteIndent = null
        this._nonMutedChildComments = this.childComments.filter(comment => {
          if (muteIndent != null) {
            if (comment.indent > muteIndent) {
              return false
            }
            muteIndent = null
          }

          if (comment.isMuted) {
            muteIndent = comment.indent
            return false
          }

          return true
        })
      }
      return this._nonMutedChildComments
    }

    /**
     * returns {number}
     */
    get childCommentCount() {
      return this.nonMutedChildComments.length
    }

    /**
     * @param {HTMLElement} $wrapper
     * @param {number} index
     */
    constructor($wrapper, index) {
      /** @type {number} */
      this.indent = Number( /** @type {HTMLImageElement} */ ($wrapper.querySelector('img[src="s.gif"]')).width)

      /** @type {number} */
      this.index = index

      let $user = /** @type {HTMLElement} */ ($wrapper.querySelector('a.hnuser'))
      /** @type {string} */
      this.user = $user?.innerText

      /** @type {HTMLElement} */
      this.$comment = $wrapper.querySelector('div.comment')

      /** @type {HTMLElement} */
      this.$topBar = $wrapper.querySelector('td.default > div')

      /** @type {HTMLElement} */
      this.$voteLinks = $wrapper.querySelector('td.votelinks')

      /** @type {HTMLElement} */
      this.$wrapper = $wrapper

      /** @private @type {HNComment[]} */
      this._childComments = null

      /** @private @type {HNComment[]} */
      this._nonMutedChildComments = null

      /**
       * The comment's id.
       * Will be `-1` for deleted comments.
       * @type {number}
       */
      this.id = -1

      /**
       * Some flagged comments are collapsed by default.
       * @type {boolean}
       */
      this.isCollapsed = $wrapper.classList.contains('coll')

      /**
       * Comments whose text has been removed but are still displayed may have
       * their text replaced with [flagged], [dead] or similar - we'll take any
       * word in square brackets as indication of this.
       * @type {boolean}
       */
      this.isDeleted = /^\s*\[\w+]\s*$/.test(this.$comment.firstChild.nodeValue)

      /**
       * The displayed age of the comment; `${n} minutes/hours/days ago`, or
       * `on ${date}` for older comments.
       * Will be blank for deleted comments.
       * @type {string}
       */
      this.when = ''

      /** @type {HTMLElement} */
      this.$childCount = null

      /** @type {HTMLElement} */
      this.$comhead = this.$topBar.querySelector('span.comhead')

      /** @type {HTMLElement} */
      this.$toggleControl = h('button', {
        className: 'toggle',
        onclick: () => this.toggleCollapsed(),
      }, this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE)

      if (!this.isDeleted) {
        let $permalink = /** @type {HTMLAnchorElement} */ (this.$topBar.querySelector('a[href^=item]'))
        this.id = Number($permalink.href.split('=').pop())
        this.when = $permalink?.textContent.replace('minute', 'min')
      }
    }

    addControls() {
      // We want to use the comment meta bar for the folding control, so put
      // it back above the deleted comment placeholder.
      if (this.isDeleted) {
        this.$topBar.style.marginBottom = '4px'
      }
      this.$topBar.insertAdjacentText('afterbegin', ' ')
      this.$topBar.insertAdjacentElement('afterbegin', this.$toggleControl)
      this.$comhead.append(...[
        // User note
        userNotes[this.user] && h('span', {className: 'note'},
          ` | nb: ${userNotes[this.user].split(/\r?\n/)[0]}`,
        ),
        // Mute control
        this.user && h('span', {className: 'mute'}, ' | ', h('a', {
          href: `mute?id=${this.user}`,
          onclick: (e) => {
            e.preventDefault()
            this.mute()
          }
        }, 'mute'))
      ].filter(Boolean))
    }

    mute() {
      mutedUsers = getMutedUsers()
      mutedUsers.add(this.user)
      storeMutedUsers(mutedUsers)

      // Invalidate non-muted child caches and update child counts on any
      // comments which have been collapsed.
      for (let i = 0; i < comments.length; i++) {
        let comment = comments[i]

        if (comment.isMuted) {
          i += comment.childComments.length
          continue
        }

        comment._nonMutedChildComments = null
        if (comment.$childCount) {
          comment.$childCount.textContent = comment.collapsedChildrenText
        }
      }

      hideMutedUsers()
    }

    /**
     * @param {boolean} updateChildren
     */
    updateDisplay(updateChildren = true) {
      // Show/hide this comment, preserving display of the meta bar
      toggleDisplay(this.$comment, this.isCollapsed)
      if (this.$voteLinks) {
        toggleVisibility(this.$voteLinks, this.isCollapsed)
      }
      this.$toggleControl.textContent = this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE

      // Show/hide the number of child comments when collapsed
      if (this.childCommentCount > 0) {
        if (this.isCollapsed && this.$childCount == null) {
          this.$childCount = h('span', null, this.collapsedChildrenText)
          this.$comhead.appendChild(this.$childCount)
        }
        toggleDisplay(this.$childCount, !this.isCollapsed)
      }

      if (updateChildren) {
        for (let i = 0; i < this.nonMutedChildComments.length; i++) {
          let child = this.nonMutedChildComments[i]
          toggleDisplay(child.$wrapper, this.isCollapsed)
          if (child.isCollapsed) {
            i += child.childComments.length
          }
        }
      }
    }

    /**
     * Completely hides this comment and its replies.
     */
    hide() {
      toggleDisplay(this.$wrapper, true)
      this.childComments.forEach((child) => toggleDisplay(child.$wrapper, true))
    }

    /**
     * @param {number} commentId
     * @returns {boolean}
     */
    hasChildCommentsNewerThan(commentId) {
      return this.nonMutedChildComments.some((comment) => comment.isNewerThan(commentId))
    }

    /**
     * @param {number} commentId
     * @returns {boolean}
     */
    isNewerThan(commentId) {
      return this.id > commentId
    }

    /**
     * @param {boolean} isCollapsed
     */
    toggleCollapsed(isCollapsed = !this.isCollapsed) {
      this.isCollapsed = isCollapsed
      this.updateDisplay()
    }

    /**
     * @param {boolean} highlight
     */
    toggleHighlighted(highlight) {
      this.$wrapper.style.backgroundColor = highlight ? HIGHLIGHT_COLOR : 'transparent'
    }
  }

  //#region Functions
  function addHighlightCommentsControl($container) {
    let $highlightComments = h('span', null, ' | ', h('a', {
      href: '#',
      onClick(e) {
        e.preventDefault()
        addTimeTravelCommentControls($container)
        $highlightComments.remove()
      },
    }, 'highlight comments'))

    $container.querySelector('.subline')?.append($highlightComments)
  }

  /**
   * Adds checkboxes to toggle folding and highlighting when there are new
   * comments on a comment page.
   * @param {HTMLElement} $container
   */
  function addNewCommentControls($container) {
    $container.appendChild(
      h('div', null,
        h('p', null,
          `${newCommentCount} new comment${s(newCommentCount)} since ${lastVisit.time.toLocaleString()}`
        ),
        h('div', {id: 'highlightControls'},
          checkbox({
            checked: autoHighlightNew,
            onclick: (e) => {
              highlightNewComments(e.target.checked, lastVisit.maxCommentId)
            },
          }, 'highlight new comments'),
          ' ',
          checkbox({
            checked: autoCollapseNotNew,
            onclick: (e) => {
              collapseThreadsWithoutNewComments(e.target.checked, lastVisit.maxCommentId)
            },
          }, 'collapse threads without new comments'),
        ),
      )
    )
  }

  /**
   * Adds the appropriate page controls depending on whether or not there are
   * new comments or any comments at all.
   */
  function addPageControls() {
    let $container = /** @type {HTMLElement} */ (document.querySelector('td.subtext'))
    if (!$container) {
      warn('no container found for page controls')
      return
    }

    if (hasNewComments) {
      addNewCommentControls($container)
    }
    else if (commentCount > 1) {
      addHighlightCommentsControl($container)
    }
  }

  /**
   * Adds a range control and button to show the last X new comments.
   */
  function addTimeTravelCommentControls($container) {
    let sortedCommentIds = []
    for (let i = 0; i < comments.length; i++) {
      let comment = comments[i]
      if (comment.isMuted) {
        // Skip muted comments and their replies as they're always hidden
        i += comment.childComments.length
        continue
      }
      sortedCommentIds.push(comment.id)
    }
    sortedCommentIds.sort()

    let showNewCommentsAfter = Math.max(0, sortedCommentIds.length - 1)
    let howMany = sortedCommentIds.length - showNewCommentsAfter

    function getRangeDescription() {
      let fromWhen = commentsById[sortedCommentIds[showNewCommentsAfter]].when
      // Older comments display `on ${date}` instead of a relative time
      if (fromWhen.startsWith(' on')) {
        fromWhen = fromWhen.replace(' on', 'since')
      }
      else {
        fromWhen = `from ${fromWhen}`
      }
      return `${howMany} ${fromWhen}`
    }

    let $description = h('span', null, getRangeDescription())

    let $range = h('input', {
      id: 'timeTravelRange',
      max: sortedCommentIds.length - 1,
      min: 1,
      oninput(e) {
        showNewCommentsAfter = Number(e.target.value)
        howMany = sortedCommentIds.length - showNewCommentsAfter
        $description.textContent = getRangeDescription()
      },
      type: 'range',
      value: sortedCommentIds.length - 1,
    })

    let $button = /** @type {HTMLInputElement} */ (h('input', {
      id: 'timeTravelButton',
      onclick() {
        let referenceCommentId = sortedCommentIds[showNewCommentsAfter - 1]
        log(`manually highlighting ${howMany} comments since ${referenceCommentId}`)
        highlightNewComments(true, referenceCommentId)
        collapseThreadsWithoutNewComments(true, referenceCommentId)
        $timeTravelControl.remove()
      },
      type: 'button',
      value: 'highlight comments',
    }))

    let $timeTravelControl = h('div', {
      id: 'timeTravel',
    }, h('div', null, $range), $button, $description)

    $container.appendChild($timeTravelControl)
  }

  /**
   * Collapses threads which don't have any comments newer than the given
   * comment id.
   * @param {boolean} collapse
   * @param {number} referenceCommentId
   */
  function collapseThreadsWithoutNewComments(collapse, referenceCommentId) {
    for (let i = 0; i < comments.length; i++) {
      let comment = comments[i]
      if (comment.isMuted) {
        // Skip muted comments and their replies as they're always hidden
        i += comment.childComments.length
        continue
      }
      if (!comment.isNewerThan(referenceCommentId) &&
          !comment.hasChildCommentsNewerThan(referenceCommentId)) {
        comment.toggleCollapsed(collapse)
        // Skip replies as we've already checked them
        i += comment.childComments.length
      }
    }
  }

  function hideMutedUsers() {
    for (let i = 0; i < comments.length; i++) {
      let comment = comments[i]
      if (comment.isMuted) {
        comment.hide()
        // Skip replies as hide() already hid them
        i += comment.childComments.length
      }
    }
  }

  /**
   * Highlights comments newer than the given comment id.
   * @param {boolean} highlight
   * @param {number} referenceCommentId
   */
  function highlightNewComments(highlight, referenceCommentId) {
    comments.forEach((comment) => {
      if (!comment.isMuted && comment.isNewerThan(referenceCommentId)) {
        comment.toggleHighlighted(highlight)
      }
    })
  }

  function initComments() {
    let commentWrappers = /** @type {NodeListOf<HTMLTableRowElement>} */ (document.querySelectorAll('table.comment-tree tr.athing'))
    log('number of comment wrappers', commentWrappers.length)

    let commentIndex = 0
    for (let $wrapper of commentWrappers) {
      let comment = new HNComment($wrapper, commentIndex++)
      comments.push(comment)
      if (!comment.isMuted && !comment.isDeleted) {
        commentsById[comment.id] = comment
      }
    }

    let lastVisitMaxCommentId = lastVisit?.maxCommentId ?? -1
    for (let i = 0; i < comments.length; i++) {
      let comment = comments[i]

      if (comment.isMuted) {
        mutedCommentCount++
        for (let j = i + 1; j <= i + comment.childComments.length; j++) {
          if (comments[j].isMuted) {
            mutedCommentCount++
          } else {
            replyToMutedCommentCount++
          }
        }
        // Skip child comments as we've already accounted for them
        i += comment.childComments.length
        // Don't consider muted comments or their replies when counting new
        // comments, or add controls to them, as they'll all be hidden.
        continue
      }

      if (!comment.isDeleted && comment.isNewerThan(lastVisitMaxCommentId)) {
        newCommentCount++
      }

      comment.addControls()
    }

    maxCommentId = comments.map(comment => comment.id).sort().pop()
    hasNewComments = lastVisit != null && newCommentCount > 0
  }

  // TODO Only store visit data when the item header is present (i.e. not a comment permalink)
  // TODO Only store visit data for commentable items (a reply box / reply links are visible)
  // TODO Clear any existing stored visit if the item is no longer commentable
  function storePageViewData() {
    storeVisit(itemId, new Visit({
      commentCount,
      maxCommentId,
      time: new Date(),
    }))
  }
  //#endregion

  //#region Main
  lastVisit = getLastVisit(itemId)

  let $commentsLink = document.querySelector('span.subline > a[href^=item]')
  if ($commentsLink && /^\d+/.test($commentsLink.textContent)) {
    commentCount = Number($commentsLink.textContent.split(/\s/).shift())
  } else {
    warn('number of comments link not found')
  }

  configureCss()
  initComments()
  // Update display of any comments which were already collapsed by HN's own
  // functionality, e.g. deleted comments
  comments.filter(comment => comment.isCollapsed).forEach(comment => comment.updateDisplay(false))
  hideMutedUsers()
  if (hasNewComments && (autoHighlightNew || autoCollapseNotNew)) {
    if (autoHighlightNew) {
      highlightNewComments(true, lastVisit.maxCommentId)
    }
    if (autoCollapseNotNew) {
      collapseThreadsWithoutNewComments(true, lastVisit.maxCommentId)
    }
  }
  addPageControls()
  storePageViewData()

  log('page view data', {
    autoHighlightNew,
    commentCount,
    mutedCommentCount,
    replyToMutedCommentCount,
    hasNewComments,
    itemId,
    lastVisit,
    maxCommentId,
    newCommentCount,
  })

  chrome.storage.local.onChanged.addListener((changes) => {
    if ('hideReplyLinks' in changes) {
      config.hideReplyLinks = changes['hideReplyLinks'].newValue
      configureCss()
    }
    if ('makeSubmissionTextReadable' in changes) {
      config.makeSubmissionTextReadable = changes['makeSubmissionTextReadable'].newValue
      configureCss()
    }
  })
  //#endregion
}
//#endregion

//#region Item list page
/**
 * Each item on an item list page has the following structure:
 *
 * ```html
 * <tr class="athing">…</td> (rank, upvote control, title/link and domain)
 * <tr>
 *   <td>…</td> (spacer)
 *   <td class="subtext">
 *     <span class="subline">…</span> (item meta info)
 *   </td>
 * </tr>
 * <tr class="spacer">…</tr>
 * ```
 *
 * Using the comment count stored when you visit a comment page, we'll display
 * the number of new comments in the subtext section and provide a link which
 * will automatically highlight new comments and collapse comment trees without
 * new comments.
 *
 * For regular stories, the subtext element contains points, user, age (in
 * a link to the comments page), flag/hide controls and finally the number of
 * comments (in another link to the comments page). We'll look for the latter
 * to detemine the current number of comments and the item id.
 *
 * For job postings, the subtext element only contains age (in
 * a link to the comments page) and a hide control, so we'll try to ignore
 * those.
 */
function itemListPage() {
  log('item list page')

  //#region CSS
  let $style = addStyle('list-dynamic')

  function configureCss() {
    $style.textContent = [
      // Hide flag links
      config.listPageFlagging == 'disabled' && `
        .flag-sep, .flag-sep + a {
          display: none;
        }
      `,
      // Hide hide links
      config.listPageHiding == 'disabled' && `
        .hide-sep, .hide-sep + a {
          display: none;
        }
      `
    ].filter(Boolean).map(dedent).join('\n')
  }
  //#endregion

  //#region Functions
  function confirmFlag(e) {
    if (config.listPageFlagging != 'confirm') return
    let title = e.target.closest('tr').previousElementSibling.querySelector('.titleline a')?.textContent || 'this item'
    if (!confirm(`Are you sure you want to flag "${title}"?`)) {
      e.stopPropagation()
      e.stopImmediatePropagation()
      e.preventDefault()
      return false
    }
  }

  function confirmHide(e) {
    if (config.listPageHiding != 'confirm') return
    let title = e.target.closest('tr').previousElementSibling.querySelector('.titleline a')?.textContent || 'this item'
    if (!confirm(`Are you sure you want to hide "${title}"?`)) {
      e.stopPropagation()
      e.stopImmediatePropagation()
      e.preventDefault()
      return false
    }
  }
  //#endregion

  //#region Main
  if (location.pathname != '/flagged') {
    for (let $flagLink of document.querySelectorAll('span.subline > a[href^="flag"]')) {
      // Wrap the '|' before flag links in an element so they can be hidden
      $flagLink.previousSibling.replaceWith(h('span', {className: 'flag-sep'}, ' | '))
      $flagLink.addEventListener('click', confirmFlag, true)
    }
  }

  if (location.pathname != '/hidden') {
    for (let $hideLink of document.querySelectorAll('span.subline > a[href^="hide"]')) {
      // Wrap the '|' before hide links in an element so they can be hidden
      $hideLink.previousSibling.replaceWith(h('span', {className: 'hide-sep'}, ' | '))
      $hideLink.addEventListener('click', confirmHide, true)
    }
  }

  let commentLinks = /** @type {NodeListOf<HTMLAnchorElement>} */ (document.querySelectorAll('span.subline > a[href^="item?id="]:last-child'))
  log('number of comments/discuss links', commentLinks.length)

  let noCommentsCount = 0
  let noLastVisitCount = 0

  for (let $commentLink of commentLinks) {
    let id = $commentLink.href.split('=').pop()

    let commentCountMatch = /^(\d+)/.exec($commentLink.textContent)
    if (commentCountMatch == null) {
      noCommentsCount++
      continue
    }

    let lastVisit = getLastVisit(id)
    if (lastVisit == null) {
      noLastVisitCount++
      continue
    }

    let commentCount = Number(commentCountMatch[1])
    if (commentCount <= lastVisit.commentCount) {
      log(`${id} doesn't have any new comments`, lastVisit)
      continue
    }

    $commentLink.insertAdjacentElement('afterend',
      h('span', null,
        ' (',
        h('a', {
            href: `item?shownew&id=${id}`,
            style: {fontWeight: 'bold'},
          },
          commentCount - lastVisit.commentCount,
          ' new'
        ),
        ')',
      )
    )
  }

  if (noCommentsCount > 0) {
    log(`${noCommentsCount} item${s(noCommentsCount, " doesn't,s don't")} have any comments`)
  }
  if (noLastVisitCount > 0) {
    log(`${noLastVisitCount} item${s(noLastVisitCount, " doesn't,s don't")} have a last visit stored`)
  }

  configureCss()

  chrome.storage.local.onChanged.addListener((changes) => {
    if ('listPageFlagging' in changes) {
      config.listPageFlagging = changes['listPageFlagging'].newValue
      configureCss()
    }
    if ('listPageHiding' in changes) {
      config.listPageHiding = changes['listPageHiding'].newValue
      configureCss()
    }
  })
  //#endregion
}
//#endregion

//#region Profile page
function userProfilePage() {
  log('user profile page')

  let $userLink = /** @type {HTMLAnchorElement} */ (document.querySelector('a.hnuser'))
  if ($userLink == null) {
    warn('not a valid user')
    return
  }

  let userId = $userLink.innerText
  let $currentUserLink = /** @type {HTMLAnchorElement} */ (document.querySelector('a#me'))
  let currentUser = $currentUserLink?.innerText ?? ''
  let mutedUsers = getMutedUsers()
  let userNotes = getUserNotes()
  let $table = $userLink.closest('table')

  if (userId == currentUser || location.pathname.startsWith('/muted')) {
    //#region Logged-in user's profile
    let $mutedUsers = createMutedUsers()

    function createMutedUsers() {
      if (mutedUsers.size == 0) {
        return h('tbody', null,
          h('tr', null,
            h('td', {valign: 'top'}, 'muted:'),
            h('td', null, 'No muted users.')
          )
        )
      }

      let first = 0
      return h('tbody', null,
        ...Array.from(mutedUsers).map((mutedUserId) => h('tr', null,
          h('td', {valign: 'top'}, first++ == 0 ? 'muted:' : ''),
          h('td', null,
            h('a', {href: `user?id=${mutedUserId}`}, mutedUserId),
            h('a', {
                href: '#',
                onClick: function(e) {
                  e.preventDefault()
                  mutedUsers = getMutedUsers()
                  mutedUsers.delete(mutedUserId)
                  storeMutedUsers(mutedUsers)
                  replaceMutedUsers()
                }
              },
              ' (', h('u', null, 'unmute'), ')'
            ),
            userNotes[mutedUserId] ? ` - ${userNotes[mutedUserId].split(/\r?\n/)[0]}` : null,
          ),
        ))
      )
    }

    function replaceMutedUsers() {
      let $newMutedUsers = createMutedUsers()
      $mutedUsers.replaceWith($newMutedUsers)
      $mutedUsers = $newMutedUsers
    }

    $table.append($mutedUsers)

    window.addEventListener('storage', (e) => {
      if (e.storageArea !== localStorage ||
          e.newValue == null ||
          e.key != MUTED_USERS_KEY && e.key != USER_NOTES_KEY) {
        return
      }

      if (e.key == MUTED_USERS_KEY) {
        mutedUsers = getMutedUsers(e.newValue)
      }
      else if (e.key == USER_NOTES_KEY) {
        userNotes = getUserNotes(e.newValue)
      }

      replaceMutedUsers()
    })
    //#endregion
  }
  else {
    //#region Other user profile
    addStyle('profile-static', `
      .saved {
        color: #000;
        opacity: 0;
      }
      .saved.show {
        animation: flash 2s forwards;
      }
      @keyframes flash {
        from {
          opacity: 0;
        }
        15% {
          opacity: 1;
          animation-timing-function: ease-in;
        }
        75% {
          opacity: 1;
        }
        to {
          opacity: 0;
          animation-timing-function: ease-out;
        }
      }
      .notes {
        display: flex;
        flex-direction: column;
        align-items: flex-start;
        gap: 3px;
      }
    `)

    function getMutedStatusText() {
      return mutedUsers.has(userId) ? 'unmute' : 'mute'
    }

    function getUserNote() {
      return userNotes[userId] || ''
    }

    function userHasNote() {
      return userNotes.hasOwnProperty(userId)
    }

    function saveNotes() {
      userNotes = getUserNotes()
      let note = $textArea.value.trim()

      // Don't save initial blanks or duplicates
      if (userNotes[userId] == note || note == '' && !userHasNote()) return

      userNotes[userId] = $textArea.value.trim()
      storeUserNotes(userNotes)

      if ($saved.classList.contains('show')) {
        $saved.classList.remove('show')
        $saved.offsetHeight
      }
      $saved.classList.add('show')
    }

    let $textArea = /** @type {HTMLTextAreaElement} */ (h('textarea', {
      cols: 60,
      value: userNotes[userId] || '',
      className: 'notes',
      style: {resize: 'none'},
      onInput() {
        autosizeTextArea(this)
      },
      onKeydown(e) {
        // Save on Use Ctrl+Enter / Cmd+Return
        if (e.key == 'Enter' && (e.ctrlKey || e.metaKey)) {
          e.preventDefault()
          saveNotes()
        }
      },
      onBlur() {
        saveNotes()
      }
    }))

    let $muted = h('u', null, getMutedStatusText())
    let $saved = h('span', {className: 'saved'}, 'saved')

    $table.querySelector('tbody').append(
      h('tr', null,
        h('td'),
        h('td', null,
          h('a', {
              href: '#',
              onClick: function(e) {
                e.preventDefault()
                if (mutedUsers.has(userId)) {
                  mutedUsers = getMutedUsers()
                  mutedUsers.delete(userId)
                  this.firstElementChild.innerText = 'mute'
                }
                else {
                  mutedUsers = getMutedUsers()
                  mutedUsers.add(userId)
                  this.firstElementChild.innerText = 'unmute'
                }
                storeMutedUsers(mutedUsers)
              }
            },
            $muted
          )
        )
      ),
      h('tr', null,
        h('td', {vAlign: 'top'}, 'notes:'),
        h('td', {className: 'notes'}, $textArea, $saved),
      ),
    )

    autosizeTextArea($textArea)

    window.addEventListener('storage', (e) => {
      if (e.storageArea !== localStorage || e.newValue == null) return

      if (e.key == MUTED_USERS_KEY) {
        mutedUsers = getMutedUsers(e.newValue)
        if ($muted.textContent != getMutedStatusText()) {
          $muted.textContent = getMutedStatusText()
        }
      }
      else if (e.key == USER_NOTES_KEY) {
        userNotes = getUserNotes(e.newValue)
        if (userHasNote() && $textArea.value.trim() != getUserNote()) {
          $textArea.value = getUserNote()
        }
      }
    })
    //#endregion
  }
}
//#endregion

//#region Main
function main() {
  log('config', config)

  if (location.pathname.startsWith('/login')) {
    log('login screen')
    if (isSafari) {
      log('trying to prevent Safari zooming in on the autofocused input')
      addStyle('login-safari', `input[type="text"], input[type="password"] { font-size: 16px; }`)
      setTimeout(() => {
        document.querySelector('input[type="password"]').focus()
        document.querySelector('input[type="text"]').focus()
      })
    }
    return
  }

  if (location.pathname.startsWith('/muted')) {
    document.documentElement.innerHTML = LOGGED_OUT_USER_PAGE
    // Safari on macOS has a default dark background in dark mode
    if (isSafari) {
      addStyle('muted-safari', 'html { background-color: #fff; }')
    }
  }

  tweakNav()

  let path = location.pathname.slice(1)

  if (/^($|active|ask|best($|\?)|flagged|front|hidden|invited|launches|news|newest|noobstories|pool|show|submitted|upvoted)/.test(path) ||
      /^favorites/.test(path) && !location.search.includes('&comments=t')) {
    itemListPage()
  }
  else if (/^item/.test(path)) {
    commentPage()
  }
  else if (/^(user|muted)/.test(path)) {
    userProfilePage()
  }
}

if (
  typeof GM == 'undefined' &&
  typeof chrome != 'undefined' &&
  typeof chrome.storage != 'undefined'
) {
  chrome.storage.local.get((storedConfig) => {
    Object.assign(config, storedConfig)
    main()
  })
}
else {
  main()
}
//#endregion