HN Comment Trees

Hide/show comment trees and highlight new comments since last visit in Hacker News

当前为 2016-12-18 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        HN Comment Trees
// @description Hide/show comment trees and highlight new comments since last visit in Hacker News
// @namespace   https://github.com/insin/greasemonkey/
// @match       https://news.ycombinator.com/*
// @grant       GM_addStyle
// @version     33
// ==/UserScript==

var COMMENT_COUNT_KEY = ':cc'
var LAST_VISIT_TIME_KEY = ':lv'
var MAX_COMMENT_ID_KEY = ':mc'

var debug = false
function LOG(...args) {
  if (!debug) return
  console.log('[HN Comment Trees]', ...args)
}

// ==================================================================== Utils ==

var Array_slice = Array.prototype.slice

function toggleDisplay(el, show) {
  el.style.display = (show ? '' : 'none')
}

/**
 * Returns the appropriate suffix based on an item count. Returns 's' for plural
 * by default.
 * @param {Number} itemCount
 * @param {String=} config plural suffix or singular and plural suffixes
 *   separated by a comma.
 */
function pluralise(itemCount, config) {
  config = config || 's'
  if (config.indexOf(',') == -1) { config = ',' + config }
  var suffixes = config.split(',').slice(0, 2)
  return (itemCount === 1 ? suffixes[0] : suffixes[1])
}

/**
 * Iterates over a list, calling the given callback with each property and
 * value. Stops iteration if the callback returns false.
 */
function forEachItem(obj, cb) {
  var props = Object.keys(obj)
  for (var i = 0, l = props.length; i < l; i++) {
    if (cb(props[i], obj[props[i]]) === false) {
      break
    }
  }
}

/**
 * Creates a DOM Element with the given tag name and attributes. Children can
 * either be given as a single list or as all additional arguments after
 * attributes.
 */
function $el(tagName, attributes, children) {
  if (!Array.isArray(children)) {
    children = Array_slice.call(arguments, 2)
  }

  var element = document.createElement(tagName)

  if (attributes) {
    forEachItem(attributes, function(prop, value) {
      if (prop.indexOf('on') === 0) {
        element.addEventListener(prop.slice(2).toLowerCase(), value)
      }
      else if (prop.toLowerCase() == 'style') {
        forEachItem(value, function(p, v) { element.style[p] = v })
      }
      else {
        element[prop] = value
      }
    })
  }

  for (var i = 0, l = children.length; i < l; i++) {
    var child = children[i]
    if (child == null || child === false) { continue }
    if (child != null && typeof child.nodeType != 'undefined') {
      // Append element children directly
      element.appendChild(children[i])
    }
    else {
      // Coerce non-element children to String and append as a text node
      element.appendChild($text(''+child))
    }
  }

  return element
}

function $text(text) {
  return document.createTextNode(text)
}

/**
 * Creates a labeled checkbox control.
 */
function $checkboxControl(labelText, defaultChecked, eventListener) {
  return $el('label', {}
  , $el('input', {type: 'checkbox', checked: defaultChecked, onClick: eventListener})
  , ' '
  , labelText
  )
}

/**
 * Gets data from localStorage.
 */
function getData(name, defaultValue) {
  var value = localStorage[name]
  return (typeof value != 'undefined' ? value : defaultValue)
}

/**
 * Sets data im localStorage.
 */
function setData(name, value) {
  localStorage[name] = value
}

// =================================================================== HNLink ==

function HNLink(linkEl, metaEl) {
  var subtext = metaEl.querySelector('td.subtext')
  var commentLink = [...subtext.querySelectorAll('a[href^=item]')].pop()

  // Job posts can't have comments
  this.isCommentable = (commentLink != null)
  if (!this.isCommentable) { return }
  this.id = commentLink.href.split('=').pop()
  this.commentCount = (/^\d+/.test(commentLink.textContent)
                       ? Number(commentLink.textContent.split(/\s/).shift())
                       : null)
  this.lastCommentCount = null

  this.els = {
    link: linkEl
  , meta: metaEl
  , subtext: subtext
  }
}

HNLink.prototype.initDOM = function() {
  if (!this.isCommentable) {
    return
  }
  if (this.commentCount != null &&
      this.lastCommentCount != null &&
      this.commentCount > this.lastCommentCount) {
    var newCommentCount = this.commentCount - this.lastCommentCount
    this.els.subtext.appendChild($el('span', null
    , ' ('
    , $el('a', {href: '/item?shownew&id=' + this.id, style: {fontWeight: 'bold'}}
      , newCommentCount
      , ' new'
      )
    , ')'
    ))
  }
}

// ================================================================ HNComment ==

/**
 * @param {Element} el the DOM element wrapping the entire comment.
 * @param {Number} index the index of the comment in the list of comments.
 */
function HNComment(el, index) {
  var topBar = el.querySelector('td.default > div')
  var comment = el.querySelector('div.comment')
  var isDeleted = /^\s*\[\w+\]\s*$/.test(comment.firstChild.nodeValue)

  if (isDeleted) {
    this.id = -1
    this.when = ''
  }
  else {
    var permalink = topBar.querySelector('a[href^=item]')
    this.id = Number(permalink.href.split('=').pop())
    this.when = permalink.textContent
  }
  
  this.index = index
  this.indent = Number(el.querySelector('img[src="s.gif"]').width)

  this.isCollapsed = false
  this.isDeleted = isDeleted
  this.isTopLevel = (this.indent === 0)

  this.els = {
    wrapper: el
  , topBar: topBar
  , vote: el.querySelector('td[valign="top"] > center')
  , comment: comment
  , reply: el.querySelector('span.comment + div.reply')
  , toggleControl: $el('span', {
      style: {cursor: 'pointer'}
    , onClick: function() { this.toggleCollapsed() }.bind(this)
    }, '[–]')
  }
}

HNComment.prototype.addToggleControlToDOM = function() {
  // We want to use the comment metadata bar for the toggle control, so put it
  // back above the [deleted] placeholder.
  if (this.isDeleted) {
    this.els.topBar.style.marginBottom = '4px';
  }
  var el = this.els.topBar
  el.insertBefore($text(' '), el.firstChild)
  el.insertBefore(this.els.toggleControl, el.firstChild)
}

/**
 * Cached getter for child comments - that is, any comments immediately
 * following this one which have a larger indent.
 */
HNComment.prototype.children = function() {
  if (typeof this._children == 'undefined') {
    this._children = []
    for (var i = this.index + 1, l = comments.length; i < l; i++) {
      var child  = comments[i]
      if (child.indent <= this.indent) { break }
      this._children.push(child)
    }
  }
  return this._children
}

/**
 * Determine if this comment has child comments which are new based on a
 * reference comment id.
 */
HNComment.prototype.hasNewComments = function(referenceCommentId) {
  var children = this.children(comments)
  var foundNewComment = false
  for (var i = 0, l = children.length; i < l; i++) {
    if (children[i].isNew(referenceCommentId)) {
      foundNewComment = true
      break
    }
  }
  return foundNewComment
}

/**
 * Determine if this comment is new based on a reference comment id.
 */
HNComment.prototype.isNew = function(referenceCommentId) {
  return (!!referenceCommentId && this.id > referenceCommentId)
}

/**
 * If given a new collapse state, applies it. Otherwise toggles the current
 * collapsed state.
 * @param {Boolean=} collapse.
 */
HNComment.prototype.toggleCollapsed = function(collapse) {
  if (arguments.length === 0) {
    collapse = !this.isCollapsed
  }
  this._updateDOMCollapsed(!collapse)
  this.isCollapsed = collapse
}

HNComment.prototype.toggleHighlighted = function(highlight) {
  this.els.wrapper.style.backgroundColor = (highlight ? '#ffffde' : 'transparent')
}

/**
 * @param {Boolean} show.
 */
HNComment.prototype._updateDOMCollapsed = function(show) {
  toggleDisplay(this.els.comment, show)
  if (this.els.reply) {
    toggleDisplay(this.els.reply, show)
  }
  if (this.els.vote) {
    this.els.vote.style.visibility = (show ? 'visible' : 'hidden')
  }
  this.els.toggleControl.textContent = (show ? '[–]' : '[+]')
  var children = this.children()
  children.forEach(function(child) {
    toggleDisplay(child.els.wrapper, show)
  })
  if (show) {
    this.els.topBar.removeChild(this.els.topBar.lastChild)
  }
  else {
    this.els.topBar.appendChild($text(
      (this.isDeleted ? '(' : ' | (') + children.length +
      ' child' + pluralise(children.length, 'ren') + ')'
    ))
  }
}

var links = []

function linkPage() {
  LOG('>>> linkPage')
  var linkNodes = document.evaluate('//tr[@class="athing"]', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
  LOG('linkNodes.snapshotLength', linkNodes.snapshotLength)
  for (var i = 0, l = linkNodes.snapshotLength; i < l; i++) {
    var linkNode = linkNodes.snapshotItem(i)
    var metaNode = linkNode.nextElementSibling
    var link = new HNLink(linkNode, metaNode)
    var lastCommentCount = getData(link.id + COMMENT_COUNT_KEY, null)
    if (lastCommentCount != null) {
      link.lastCommentCount = Number(lastCommentCount)
    }
    LOG(link)
    links.push(link)
  }

  links.forEach(function(link) {
    link.initDOM()
  })
  LOG('<<< linkPage')
}

var comments = []
var commentsById = {}

function commentPage() {
  LOG('>>> commentPage')
  
  // Hide new built-in comment toggling
  GM_addStyle('a.togg { display: none; }')

  var itemId = location.search.split('=').pop()
  var maxCommentIdKey = itemId + MAX_COMMENT_ID_KEY
  var lastVisitKey = itemId + LAST_VISIT_TIME_KEY
  var lastMaxCommentId = Number(getData(maxCommentIdKey, '0'))
  var lastVisit = getData(lastVisitKey, null)
  if (typeof lastVisit != 'undefined') {
    lastVisit = new Date(Number(lastVisit))
  }
  var maxCommentId = -1
  var newCommentCount = 0
  LOG({itemId, maxCommentIdKey, lastVisitKey, lastMaxCommentId, lastVisit})

  var commentNodes = document.evaluate('//table[@class="comment-tree"]//tr[contains(@class,"athing")]', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
  LOG('commentNodes.snapshotLength', commentNodes.snapshotLength)

  for (var i = 0, l = commentNodes.snapshotLength; i < l; i++) {
    var wrapper = commentNodes.snapshotItem(i)
    var comment = new HNComment(wrapper, i)
    if (comment.id > maxCommentId) {
      maxCommentId = comment.id
    }
    if (comment.isNew(lastMaxCommentId)) {
      newCommentCount++
    }
    comments.push(comment)
    if (comment.id !== -1) {
      commentsById[comment.id] = comment
    }
  }
  LOG({maxCommentId, newCommentCount})

  function highlightNewComments(highlight, referenceCommentId) {
    comments.forEach(function(comment) {
      if (comment.isNew(referenceCommentId)) {
        comment.toggleHighlighted(highlight)
      }
    })
  }

  function collapseThreadsWithoutNewComments(collapse, referenceCommentId) {
    for (var i = 0, l = comments.length; i < l; i++) {
      var comment = comments[i]
      if (!comment.isNew(referenceCommentId) && !comment.hasNewComments(referenceCommentId)) {
        comment.toggleCollapsed(collapse)
        i += comment.children(comments).length
      }
    }
  }

  var highlightNew = (location.search.indexOf('?shownew') != -1)

  comments.forEach(function(comment) {
    comment.addToggleControlToDOM()
  })

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

  if (lastVisit && newCommentCount > 0) {
    var el = (document.querySelector('form[action="/r"]') ||
              document.querySelector('td.subtext'))
    if (el) {
      el.appendChild($el('div', null
      , $el('p', null
        , (newCommentCount + ' new comment' + pluralise(newCommentCount) +
           ' since ' + lastVisit.toLocaleString())
        )
      , $el('div', null
        , $checkboxControl('highlight new comments', highlightNew, function() {
            highlightNewComments(this.checked, lastMaxCommentId)
          })
        , ' '
        , $checkboxControl('collapse threads without new comments', highlightNew, function() {
            collapseThreadsWithoutNewComments(this.checked, lastMaxCommentId)
          })
        )
      ))
    }

    if (highlightNew) {
      highlightNewComments(true, lastMaxCommentId)
      collapseThreadsWithoutNewComments(true, lastMaxCommentId)
    }
  }
  else if (commentCount > 1) {
    var sortedCommentIds = comments.map(comment => comment.id)
                                   .filter(id => id !== -1)
                                   .sort((a, b) => a - b)
    var showNewCommentsAfter = sortedCommentIds.length - 1

    var el = (document.querySelector('form[action="/r"]') ||
              document.querySelector('td.subtext'))
    
    function getButtonLabel() {
      var howMany = sortedCommentIds.length - showNewCommentsAfter
      var fromWhen = commentsById[sortedCommentIds[showNewCommentsAfter]].when
      return `highlight ${howMany} comment${pluralise(howMany)} from ${fromWhen}`
    }
    
    var $buttonLabel = $el('span', null, getButtonLabel())
    var $range = $el('input', {
      type: 'range',
      min: 1,
      max: sortedCommentIds.length - 1,
      onInput(e) {
        showNewCommentsAfter = Number(e.target.value)
        $buttonLabel.innerText = getButtonLabel()
      },
      style: {margin: 0, verticalAlign: 'middle'},
      value: sortedCommentIds.length - 1,
    })
    var $button = $el('button', {
      type: 'button',
      onClick(e) {
        var referenceCommentId = sortedCommentIds[showNewCommentsAfter - 1]
        highlightNewComments(true, referenceCommentId)
        collapseThreadsWithoutNewComments(true, referenceCommentId)
        el.removeChild($timeTravelControl)
      },
      style: {fontFamily: 'monospace', fontSize: '10pt'}
    }, $buttonLabel)
    var $timeTravelControl = $el('div', {style: {marginTop: '1em'}}, $range, ' ', $button)
  
    el.appendChild($timeTravelControl)
  }

  if (location.pathname == '/item') {
    if (maxCommentId > lastMaxCommentId) {
      setData(maxCommentIdKey, ''+maxCommentId)
    }
    setData(lastVisitKey, ''+(new Date().getTime()))
    if (commentCount) {
      setData(itemId + COMMENT_COUNT_KEY, commentsLink.textContent.split(/\s/).shift())
    }
  }
  LOG('<<< commentPage')
}

// Initialise pagetype-specific enhancments
void function() {
  var path = location.pathname.slice(1)
  if (/^(?:$|active|ask|best|news|newest|noobstories|show|submitted|upvoted)/.test(path)) { return linkPage }
  if (/^item/.test(path)) { return commentPage }
  if (/^x/.test(path)) { return (document.title.indexOf('more comments') == 0 ? commentPage : linkPage) }
  return function() {}
}()()

// Add an "upvoted" link to the top bar
if (window.location.pathname !== '/upvoted') {
  var userName = document.querySelector('span.pagetop a[href^="user?id"]').textContent
  var pageTop = document.querySelector('span.pagetop')
  pageTop.appendChild($text(' | '))
  pageTop.appendChild($el('a', {href: '/upvoted?id=' + userName}, 'upvoted'))
}