Greasy Fork is available in English.

HN Comments Owl

Highlight new Hacker News comments, block users and other UX tweaks

// ==UserScript==
// @name        HN Comments Owl
// @description Highlight new Hacker News comments, block users and other UX tweaks
// @namespace   https://github.com/insin/hn-comments-owl/
// @match       https://news.ycombinator.com/*
// @version     39
// ==/UserScript==
const HIGHLIGHT_COLOR = '#ffffde'
const TOGGLE_HIDE = '[–]'
const TOGGLE_SHOW = '[+]'

let config = {
  addUpvotedToHeader: true,
  autoHighlightNew: true,
  hideReplyLinks: false,
}

config.enableDebugLogging = false

//#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))
}

function getIgnoredUsers() {
  return new Set(JSON.parse(localStorage.ignoredUsers || '[]'))
}

function setIgnoredUsers(ignoredUsers) {
  localStorage.ignoredUsers = JSON.stringify(Array.from(ignoredUsers))
}
//#endregion

//#region Utility functions
function addStyle(css = '') {
  let $style = document.createElement('style')
  if (css) {
    $style.textContent = css
  }
  document.querySelector('head').appendChild($style)
  return $style
}

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

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 (config.enableDebugLogging) {
    console.log('🦉', ...args)
  }
}

function s(count, suffixes = ',s') {
  if (!suffixes.includes(',')) {
    suffixes = `,${suffixes}`
  }
  return suffixes.split(',')[count === 1 ? 0 : 1]
}

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' : ''
}

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 Feature: add upvoted link to header
function addUpvotedLinkToHeader() {
  if (window.location.pathname == '/upvoted') {
    return
  }

  let $userLink = document.querySelector('span.pagetop a[href^="user?id"]')
  if (!$userLink) {
    return
  }

  let $pageTop = document.querySelector('span.pagetop')
  $pageTop.insertAdjacentText('beforeend', ' | ')
  $pageTop.appendChild(h('a', {
    href: `/upvoted?id=${$userLink.textContent}`,
  }, 'upvoted'))
}
//#endregion

//#region Feature: new comment highlighting on comment pages
/**
 * 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> (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')

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

  /** @type {number} */
  let commentCount = 0

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

  /** @type {Object.<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 = -1

  /** @type {number} */
  let newCommentCount = 0

  /** @type {Set<string>} */
  let ignoredUsers = getIgnoredUsers()

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

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

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

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

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

      /** @type {Element} */
      this.$vote = $wrapper.querySelector('td[valign="top"] > center')

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

      /** @private @type {HNComment[]} */
      this._childComments = 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 {Element} */
      this.$collapsedChildCount = null

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

      /** @type {Element} */
      this.$toggleControl = h('span', {
        onclick: () => this.toggleCollapsed(),
        style: {cursor: 'pointer'},
      }, this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE)

      if (!this.isDeleted) {
        let $permalink = this.$topBar.querySelector('a[href^=item]')
        this.id = Number($permalink.href.split('=').pop())
        this.when = $permalink.textContent
      }

      this.initDOM()
    }

    initDOM() {
      // 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)
    }

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

      // Show/hide the number of child comments when collapsed
      if (this.isCollapsed && this.$collapsedChildCount == null) {
        let collapsedCommentCount = [
          this.isDeleted ? '(' : ' | (',
          this.childComments.length,
          ` child${s(this.childComments.length, 'ren')})`,
        ].join('')
        this.$collapsedChildCount = h('span', null, collapsedCommentCount)
        this.$comhead.appendChild(this.$collapsedChildCount)
      }
      toggleDisplay(this.$collapsedChildCount, !this.isCollapsed)

      // Completely show/hide any child comments
      if (updateChildren) {
        this.childComments.forEach((child) => toggleDisplay(child.$wrapper, this.isCollapsed))
      }
    }

    /**
     * @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
    }

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

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

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

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

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

  /**
   * Adds a range control and button to show the last X new comments.
   */
  function addTimeTravelCommentControls($container) {
    let sortedCommentIds = comments.map((comment) => comment.id)
      .filter(id => id !== -1)
      .sort((a, b) => a - b)

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

    function getButtonLabel() {
      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 `highlight ${howMany} comment${s(howMany)} ${fromWhen}`
    }

    let $range = h('input', {
      max: sortedCommentIds.length - 1,
      min: 1,
      oninput(e) {
        showNewCommentsAfter = Number(e.target.value)
        howMany = sortedCommentIds.length - showNewCommentsAfter
        $button.value = getButtonLabel()
      },
      style: {margin: 0, verticalAlign: 'middle'},
      type: 'range',
      value: sortedCommentIds.length - 1,
    })

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

    let $timeTravelControl = h('div', {
      style: {marginTop: '1em'},
    }, $range, ' ', $button)

    $container.appendChild($timeTravelControl)
  }

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

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

  /**
   * Collapses threads which don't have any comments newer than the given
   * comment id.
   * @param collapse {boolean}
   * @param referenceCommentId {number}
   */
  function collapseThreadsWithoutNewComments(collapse, referenceCommentId) {
    for (let i = 0; i < comments.length; i++) {
      let comment = comments[i]
      if (!comment.isNewerThan(referenceCommentId) &&
          !comment.hasChildCommentsNewerThan(referenceCommentId)) {
        comment.toggleCollapsed(collapse)
        // Skip over child comments
        i += comment.childComments.length
      }
    }
  }

  function hideBuiltInCommentFoldingControls() {
    addStyle('a.togg { display: none; }')
  }

  let toggleHideReplyLinks = (function() {
    let $style = addStyle()
    return () => {
      $style.textContent = config.hideReplyLinks ? `
        div.reply { margin-top: 8px; }
        div.reply p { display: none; }
      ` : ''
    }
  })()

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

  function initComments() {
    let commentWrappers = document.querySelectorAll('table.comment-tree tr.athing')
    log('number of comment wrappers', commentWrappers.length)
    let index = 0
    let lastMaxCommentId = lastVisit != null ? lastVisit.maxCommentId : -1
    let ignoreIndent = null
    for (let $wrapper of commentWrappers) {
      let comment = new HNComment($wrapper, index++)

      // Remove comments in threads under ignored users
      if (ignoreIndent != null) {
        if (comment.indent > ignoreIndent) {
          toggleDisplay($wrapper, true)
          continue
        }
        ignoreIndent = null
      }

      // Remove comment from ignored users
      if (ignoredUsers.has(comment.user)) {
        toggleDisplay($wrapper, true)
        ignoreIndent = comment.indent
        continue
      }

      if (comment.id > maxCommentId) {
        maxCommentId = comment.id
      }

      if (comment.isNewerThan(lastMaxCommentId)) {
        newCommentCount++
      }

      comments.push(comment)

      if (comment.id != -1) {
        commentsById[comment.id] = comment
      }
    }
    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(),
    }))
  }

  lastVisit = getLastVisit(itemId)

  let $commentsLink = document.querySelector('td.subtext > a[href^=item]')
  if ($commentsLink && /^\d+/.test($commentsLink.textContent)) {
    commentCount = Number($commentsLink.textContent.split(/\s/).shift())
  }

  hideBuiltInCommentFoldingControls()
  toggleHideReplyLinks()
  initComments()
  comments.filter(comment => comment.isCollapsed).forEach(comment => comment.updateDisplay(false))
  if (hasNewComments && autoHighlightNew) {
    highlightNewComments(true, lastVisit.maxCommentId)
    collapseThreadsWithoutNewComments(true, lastVisit.maxCommentId)
  }
  addPageControls()
  storePageViewData()

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

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

//#region Feature: new comment indicators on link pages
/**
 * 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">…</td> (item meta info)
 * </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')

  let commentLinks = document.querySelectorAll('td.subtext > 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`)
  }
}
//#endregion

//#region Feature: block/unblock users on profile pages
function userProfilePage() {
  log('user profile page')

  let $userLink = document.querySelector('a.hnuser')
  if ($userLink == null) {
    log('not a valid user')
    return
  }

  let userId = $userLink.innerText
  let $currentUserLink = document.querySelector('a#me')
  let currentUser = $currentUserLink ? $currentUserLink.innerText : ''
  let ignoredUsers = getIgnoredUsers()
  let $tbody = $userLink.closest('table').querySelector('tbody')

  if (userId == currentUser) {
    let first = 0
    ignoredUsers.forEach((ignoredUserId) => {
      $tbody.appendChild(
        h('tr', null,
          h('td', {valign: 'top'}, first++ == 0 ? 'blocked:' : ''),
          h('td', null,
            h('a', {href: `/user?id=${ignoredUserId}`}, ignoredUserId),
            h('a', {
                href: '#',
                onClick: function(e) {
                  e.preventDefault()
                  if (ignoredUsers.has(ignoredUserId)) {
                    ignoredUsers.delete(ignoredUserId)
                    this.firstElementChild.innerText = 'block'
                  }
                  else {
                    ignoredUsers.add(ignoredUserId)
                    this.firstElementChild.innerText = 'unblock'
                  }
                  setIgnoredUsers(ignoredUsers)
                }
              },
              ' (', h('u', null, 'unblock'), ')'
            )
          )
        )
      )
    })
  }
  else {
    $tbody.appendChild(
      h('tr', null,
        h('td'),
        h('td', null,
          h('a', {
              href: '#',
              onClick: function(e) {
                e.preventDefault()
                if (ignoredUsers.has(userId)) {
                  ignoredUsers.delete(userId)
                  this.firstElementChild.innerText = 'block'
                }
                else {
                  ignoredUsers.add(userId)
                  this.firstElementChild.innerText = 'unblock'
                }
                setIgnoredUsers(ignoredUsers)
              }
            },
            h('u', null, ignoredUsers.has(userId) ? 'unblock' : 'block')
          )
        )
      )
    )
  }
}
//#endregion

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

  if (config.addUpvotedToHeader) {
    addUpvotedLinkToHeader()
  }

  let path = location.pathname.slice(1)

  if (/^($|active|ask|best|front|news|newest|noobstories|show|submitted|upvoted)/.test(path)) {
    itemListPage()
  }
  else if (/^item/.test(path)) {
    commentPage()
  }
  else if (/^user/.test(path)) {
    userProfilePage()
  }
}

main()
//#endregion