HN Comment Trees

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

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