HN Comment Trees

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

目前为 2016-12-18 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name HN Comment Trees
  3. // @description Hide/show comment trees and highlight new comments since last visit in Hacker News
  4. // @namespace https://github.com/insin/greasemonkey/
  5. // @match https://news.ycombinator.com/*
  6. // @grant GM_addStyle
  7. // @version 33
  8. // ==/UserScript==
  9.  
  10. var COMMENT_COUNT_KEY = ':cc'
  11. var LAST_VISIT_TIME_KEY = ':lv'
  12. var MAX_COMMENT_ID_KEY = ':mc'
  13.  
  14. var debug = false
  15. function LOG(...args) {
  16. if (!debug) return
  17. console.log('[HN Comment Trees]', ...args)
  18. }
  19.  
  20. // ==================================================================== Utils ==
  21.  
  22. var Array_slice = Array.prototype.slice
  23.  
  24. function toggleDisplay(el, show) {
  25. el.style.display = (show ? '' : 'none')
  26. }
  27.  
  28. /**
  29. * Returns the appropriate suffix based on an item count. Returns 's' for plural
  30. * by default.
  31. * @param {Number} itemCount
  32. * @param {String=} config plural suffix or singular and plural suffixes
  33. * separated by a comma.
  34. */
  35. function pluralise(itemCount, config) {
  36. config = config || 's'
  37. if (config.indexOf(',') == -1) { config = ',' + config }
  38. var suffixes = config.split(',').slice(0, 2)
  39. return (itemCount === 1 ? suffixes[0] : suffixes[1])
  40. }
  41.  
  42. /**
  43. * Iterates over a list, calling the given callback with each property and
  44. * value. Stops iteration if the callback returns false.
  45. */
  46. function forEachItem(obj, cb) {
  47. var props = Object.keys(obj)
  48. for (var i = 0, l = props.length; i < l; i++) {
  49. if (cb(props[i], obj[props[i]]) === false) {
  50. break
  51. }
  52. }
  53. }
  54.  
  55. /**
  56. * Creates a DOM Element with the given tag name and attributes. Children can
  57. * either be given as a single list or as all additional arguments after
  58. * attributes.
  59. */
  60. function $el(tagName, attributes, children) {
  61. if (!Array.isArray(children)) {
  62. children = Array_slice.call(arguments, 2)
  63. }
  64.  
  65. var element = document.createElement(tagName)
  66.  
  67. if (attributes) {
  68. forEachItem(attributes, function(prop, value) {
  69. if (prop.indexOf('on') === 0) {
  70. element.addEventListener(prop.slice(2).toLowerCase(), value)
  71. }
  72. else if (prop.toLowerCase() == 'style') {
  73. forEachItem(value, function(p, v) { element.style[p] = v })
  74. }
  75. else {
  76. element[prop] = value
  77. }
  78. })
  79. }
  80.  
  81. for (var i = 0, l = children.length; i < l; i++) {
  82. var child = children[i]
  83. if (child == null || child === false) { continue }
  84. if (child != null && typeof child.nodeType != 'undefined') {
  85. // Append element children directly
  86. element.appendChild(children[i])
  87. }
  88. else {
  89. // Coerce non-element children to String and append as a text node
  90. element.appendChild($text(''+child))
  91. }
  92. }
  93.  
  94. return element
  95. }
  96.  
  97. function $text(text) {
  98. return document.createTextNode(text)
  99. }
  100.  
  101. /**
  102. * Creates a labeled checkbox control.
  103. */
  104. function $checkboxControl(labelText, defaultChecked, eventListener) {
  105. return $el('label', {}
  106. , $el('input', {type: 'checkbox', checked: defaultChecked, onClick: eventListener})
  107. , ' '
  108. , labelText
  109. )
  110. }
  111.  
  112. /**
  113. * Gets data from localStorage.
  114. */
  115. function getData(name, defaultValue) {
  116. var value = localStorage[name]
  117. return (typeof value != 'undefined' ? value : defaultValue)
  118. }
  119.  
  120. /**
  121. * Sets data im localStorage.
  122. */
  123. function setData(name, value) {
  124. localStorage[name] = value
  125. }
  126.  
  127. // =================================================================== HNLink ==
  128.  
  129. function HNLink(linkEl, metaEl) {
  130. var subtext = metaEl.querySelector('td.subtext')
  131. var commentLink = [...subtext.querySelectorAll('a[href^=item]')].pop()
  132.  
  133. // Job posts can't have comments
  134. this.isCommentable = (commentLink != null)
  135. if (!this.isCommentable) { return }
  136. this.id = commentLink.href.split('=').pop()
  137. this.commentCount = (/^\d+/.test(commentLink.textContent)
  138. ? Number(commentLink.textContent.split(/\s/).shift())
  139. : null)
  140. this.lastCommentCount = null
  141.  
  142. this.els = {
  143. link: linkEl
  144. , meta: metaEl
  145. , subtext: subtext
  146. }
  147. }
  148.  
  149. HNLink.prototype.initDOM = function() {
  150. if (!this.isCommentable) {
  151. return
  152. }
  153. if (this.commentCount != null &&
  154. this.lastCommentCount != null &&
  155. this.commentCount > this.lastCommentCount) {
  156. var newCommentCount = this.commentCount - this.lastCommentCount
  157. this.els.subtext.appendChild($el('span', null
  158. , ' ('
  159. , $el('a', {href: '/item?shownew&id=' + this.id, style: {fontWeight: 'bold'}}
  160. , newCommentCount
  161. , ' new'
  162. )
  163. , ')'
  164. ))
  165. }
  166. }
  167.  
  168. // ================================================================ HNComment ==
  169.  
  170. /**
  171. * @param {Element} el the DOM element wrapping the entire comment.
  172. * @param {Number} index the index of the comment in the list of comments.
  173. */
  174. function HNComment(el, index) {
  175. var topBar = el.querySelector('td.default > div')
  176. var comment = el.querySelector('div.comment')
  177. var isDeleted = /^\s*\[\w+\]\s*$/.test(comment.firstChild.nodeValue)
  178.  
  179. if (isDeleted) {
  180. this.id = -1
  181. this.when = ''
  182. }
  183. else {
  184. var permalink = topBar.querySelector('a[href^=item]')
  185. this.id = Number(permalink.href.split('=').pop())
  186. this.when = permalink.textContent
  187. }
  188. this.index = index
  189. this.indent = Number(el.querySelector('img[src="s.gif"]').width)
  190.  
  191. this.isCollapsed = false
  192. this.isDeleted = isDeleted
  193. this.isTopLevel = (this.indent === 0)
  194.  
  195. this.els = {
  196. wrapper: el
  197. , topBar: topBar
  198. , vote: el.querySelector('td[valign="top"] > center')
  199. , comment: comment
  200. , reply: el.querySelector('span.comment + div.reply')
  201. , toggleControl: $el('span', {
  202. style: {cursor: 'pointer'}
  203. , onClick: function() { this.toggleCollapsed() }.bind(this)
  204. }, '[–]')
  205. }
  206. }
  207.  
  208. HNComment.prototype.addToggleControlToDOM = function() {
  209. // We want to use the comment metadata bar for the toggle control, so put it
  210. // back above the [deleted] placeholder.
  211. if (this.isDeleted) {
  212. this.els.topBar.style.marginBottom = '4px';
  213. }
  214. var el = this.els.topBar
  215. el.insertBefore($text(' '), el.firstChild)
  216. el.insertBefore(this.els.toggleControl, el.firstChild)
  217. }
  218.  
  219. /**
  220. * Cached getter for child comments - that is, any comments immediately
  221. * following this one which have a larger indent.
  222. */
  223. HNComment.prototype.children = function() {
  224. if (typeof this._children == 'undefined') {
  225. this._children = []
  226. for (var i = this.index + 1, l = comments.length; i < l; i++) {
  227. var child = comments[i]
  228. if (child.indent <= this.indent) { break }
  229. this._children.push(child)
  230. }
  231. }
  232. return this._children
  233. }
  234.  
  235. /**
  236. * Determine if this comment has child comments which are new based on a
  237. * reference comment id.
  238. */
  239. HNComment.prototype.hasNewComments = function(referenceCommentId) {
  240. var children = this.children(comments)
  241. var foundNewComment = false
  242. for (var i = 0, l = children.length; i < l; i++) {
  243. if (children[i].isNew(referenceCommentId)) {
  244. foundNewComment = true
  245. break
  246. }
  247. }
  248. return foundNewComment
  249. }
  250.  
  251. /**
  252. * Determine if this comment is new based on a reference comment id.
  253. */
  254. HNComment.prototype.isNew = function(referenceCommentId) {
  255. return (!!referenceCommentId && this.id > referenceCommentId)
  256. }
  257.  
  258. /**
  259. * If given a new collapse state, applies it. Otherwise toggles the current
  260. * collapsed state.
  261. * @param {Boolean=} collapse.
  262. */
  263. HNComment.prototype.toggleCollapsed = function(collapse) {
  264. if (arguments.length === 0) {
  265. collapse = !this.isCollapsed
  266. }
  267. this._updateDOMCollapsed(!collapse)
  268. this.isCollapsed = collapse
  269. }
  270.  
  271. HNComment.prototype.toggleHighlighted = function(highlight) {
  272. this.els.wrapper.style.backgroundColor = (highlight ? '#ffffde' : 'transparent')
  273. }
  274.  
  275. /**
  276. * @param {Boolean} show.
  277. */
  278. HNComment.prototype._updateDOMCollapsed = function(show) {
  279. toggleDisplay(this.els.comment, show)
  280. if (this.els.reply) {
  281. toggleDisplay(this.els.reply, show)
  282. }
  283. if (this.els.vote) {
  284. this.els.vote.style.visibility = (show ? 'visible' : 'hidden')
  285. }
  286. this.els.toggleControl.textContent = (show ? '[–]' : '[+]')
  287. var children = this.children()
  288. children.forEach(function(child) {
  289. toggleDisplay(child.els.wrapper, show)
  290. })
  291. if (show) {
  292. this.els.topBar.removeChild(this.els.topBar.lastChild)
  293. }
  294. else {
  295. this.els.topBar.appendChild($text(
  296. (this.isDeleted ? '(' : ' | (') + children.length +
  297. ' child' + pluralise(children.length, 'ren') + ')'
  298. ))
  299. }
  300. }
  301.  
  302. var links = []
  303.  
  304. function linkPage() {
  305. LOG('>>> linkPage')
  306. var linkNodes = document.evaluate('//tr[@class="athing"]', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
  307. LOG('linkNodes.snapshotLength', linkNodes.snapshotLength)
  308. for (var i = 0, l = linkNodes.snapshotLength; i < l; i++) {
  309. var linkNode = linkNodes.snapshotItem(i)
  310. var metaNode = linkNode.nextElementSibling
  311. var link = new HNLink(linkNode, metaNode)
  312. var lastCommentCount = getData(link.id + COMMENT_COUNT_KEY, null)
  313. if (lastCommentCount != null) {
  314. link.lastCommentCount = Number(lastCommentCount)
  315. }
  316. LOG(link)
  317. links.push(link)
  318. }
  319.  
  320. links.forEach(function(link) {
  321. link.initDOM()
  322. })
  323. LOG('<<< linkPage')
  324. }
  325.  
  326. var comments = []
  327. var commentsById = {}
  328.  
  329. function commentPage() {
  330. LOG('>>> commentPage')
  331. // Hide new built-in comment toggling
  332. GM_addStyle('a.togg { display: none; }')
  333.  
  334. var itemId = location.search.split('=').pop()
  335. var maxCommentIdKey = itemId + MAX_COMMENT_ID_KEY
  336. var lastVisitKey = itemId + LAST_VISIT_TIME_KEY
  337. var lastMaxCommentId = Number(getData(maxCommentIdKey, '0'))
  338. var lastVisit = getData(lastVisitKey, null)
  339. if (typeof lastVisit != 'undefined') {
  340. lastVisit = new Date(Number(lastVisit))
  341. }
  342. var maxCommentId = -1
  343. var newCommentCount = 0
  344. LOG({itemId, maxCommentIdKey, lastVisitKey, lastMaxCommentId, lastVisit})
  345.  
  346. var commentNodes = document.evaluate('//table[@class="comment-tree"]//tr[contains(@class,"athing")]', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
  347. LOG('commentNodes.snapshotLength', commentNodes.snapshotLength)
  348.  
  349. for (var i = 0, l = commentNodes.snapshotLength; i < l; i++) {
  350. var wrapper = commentNodes.snapshotItem(i)
  351. var comment = new HNComment(wrapper, i)
  352. if (comment.id > maxCommentId) {
  353. maxCommentId = comment.id
  354. }
  355. if (comment.isNew(lastMaxCommentId)) {
  356. newCommentCount++
  357. }
  358. comments.push(comment)
  359. if (comment.id !== -1) {
  360. commentsById[comment.id] = comment
  361. }
  362. }
  363. LOG({maxCommentId, newCommentCount})
  364.  
  365. function highlightNewComments(highlight, referenceCommentId) {
  366. comments.forEach(function(comment) {
  367. if (comment.isNew(referenceCommentId)) {
  368. comment.toggleHighlighted(highlight)
  369. }
  370. })
  371. }
  372.  
  373. function collapseThreadsWithoutNewComments(collapse, referenceCommentId) {
  374. for (var i = 0, l = comments.length; i < l; i++) {
  375. var comment = comments[i]
  376. if (!comment.isNew(referenceCommentId) && !comment.hasNewComments(referenceCommentId)) {
  377. comment.toggleCollapsed(collapse)
  378. i += comment.children(comments).length
  379. }
  380. }
  381. }
  382.  
  383. var highlightNew = (location.search.indexOf('?shownew') != -1)
  384.  
  385. comments.forEach(function(comment) {
  386. comment.addToggleControlToDOM()
  387. })
  388.  
  389. var commentCount = 0
  390. if (location.pathname == '/item') {
  391. var commentsLink = document.querySelector('td.subtext > a[href^=item]')
  392. if (commentsLink && /^\d+/.test(commentsLink.textContent)) {
  393. commentCount = Number(commentsLink.textContent.split(/\s/).shift())
  394. }
  395. }
  396.  
  397. if (lastVisit && newCommentCount > 0) {
  398. var el = (document.querySelector('form[action="/r"]') ||
  399. document.querySelector('td.subtext'))
  400. if (el) {
  401. el.appendChild($el('div', null
  402. , $el('p', null
  403. , (newCommentCount + ' new comment' + pluralise(newCommentCount) +
  404. ' since ' + lastVisit.toLocaleString())
  405. )
  406. , $el('div', null
  407. , $checkboxControl('highlight new comments', highlightNew, function() {
  408. highlightNewComments(this.checked, lastMaxCommentId)
  409. })
  410. , ' '
  411. , $checkboxControl('collapse threads without new comments', highlightNew, function() {
  412. collapseThreadsWithoutNewComments(this.checked, lastMaxCommentId)
  413. })
  414. )
  415. ))
  416. }
  417.  
  418. if (highlightNew) {
  419. highlightNewComments(true, lastMaxCommentId)
  420. collapseThreadsWithoutNewComments(true, lastMaxCommentId)
  421. }
  422. }
  423. else if (commentCount > 1) {
  424. var sortedCommentIds = comments.map(comment => comment.id)
  425. .filter(id => id !== -1)
  426. .sort((a, b) => a - b)
  427. var showNewCommentsAfter = sortedCommentIds.length - 1
  428.  
  429. var el = (document.querySelector('form[action="/r"]') ||
  430. document.querySelector('td.subtext'))
  431. function getButtonLabel() {
  432. var howMany = sortedCommentIds.length - showNewCommentsAfter
  433. var fromWhen = commentsById[sortedCommentIds[showNewCommentsAfter]].when
  434. return `highlight ${howMany} comment${pluralise(howMany)} from ${fromWhen}`
  435. }
  436. var $buttonLabel = $el('span', null, getButtonLabel())
  437. var $range = $el('input', {
  438. type: 'range',
  439. min: 1,
  440. max: sortedCommentIds.length - 1,
  441. onInput(e) {
  442. showNewCommentsAfter = Number(e.target.value)
  443. $buttonLabel.innerText = getButtonLabel()
  444. },
  445. style: {margin: 0, verticalAlign: 'middle'},
  446. value: sortedCommentIds.length - 1,
  447. })
  448. var $button = $el('button', {
  449. type: 'button',
  450. onClick(e) {
  451. var referenceCommentId = sortedCommentIds[showNewCommentsAfter - 1]
  452. highlightNewComments(true, referenceCommentId)
  453. collapseThreadsWithoutNewComments(true, referenceCommentId)
  454. el.removeChild($timeTravelControl)
  455. },
  456. style: {fontFamily: 'monospace', fontSize: '10pt'}
  457. }, $buttonLabel)
  458. var $timeTravelControl = $el('div', {style: {marginTop: '1em'}}, $range, ' ', $button)
  459. el.appendChild($timeTravelControl)
  460. }
  461.  
  462. if (location.pathname == '/item') {
  463. if (maxCommentId > lastMaxCommentId) {
  464. setData(maxCommentIdKey, ''+maxCommentId)
  465. }
  466. setData(lastVisitKey, ''+(new Date().getTime()))
  467. if (commentCount) {
  468. setData(itemId + COMMENT_COUNT_KEY, commentsLink.textContent.split(/\s/).shift())
  469. }
  470. }
  471. LOG('<<< commentPage')
  472. }
  473.  
  474. // Initialise pagetype-specific enhancments
  475. void function() {
  476. var path = location.pathname.slice(1)
  477. if (/^(?:$|active|ask|best|news|newest|noobstories|show|submitted|upvoted)/.test(path)) { return linkPage }
  478. if (/^item/.test(path)) { return commentPage }
  479. if (/^x/.test(path)) { return (document.title.indexOf('more comments') == 0 ? commentPage : linkPage) }
  480. return function() {}
  481. }()()
  482.  
  483. // Add an "upvoted" link to the top bar
  484. if (window.location.pathname !== '/upvoted') {
  485. var userName = document.querySelector('span.pagetop a[href^="user?id"]').textContent
  486. var pageTop = document.querySelector('span.pagetop')
  487. pageTop.appendChild($text(' | '))
  488. pageTop.appendChild($el('a', {href: '/upvoted?id=' + userName}, 'upvoted'))
  489. }