HN Comment Trees

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

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

  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 26
  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(document.createTextNode(''+child))
  91. }
  92. }
  93.  
  94. return element
  95. }
  96.  
  97. /**
  98. * Creates a labeled checkbox control.
  99. */
  100. function $checkboxControl(labelText, defaultChecked, eventListener) {
  101. return $el('label', {}
  102. , $el('input', {type: 'checkbox', checked: defaultChecked, onClick: eventListener})
  103. , ' '
  104. , labelText
  105. )
  106. }
  107.  
  108. /**
  109. * Gets data from localStorage.
  110. */
  111. function getData(name, defaultValue) {
  112. var value = localStorage[name]
  113. return (typeof value != 'undefined' ? value : defaultValue)
  114. }
  115.  
  116. /**
  117. * Sets data im localStorage.
  118. */
  119. function setData(name, value) {
  120. localStorage[name] = value
  121. }
  122.  
  123. // =================================================================== HNLink ==
  124.  
  125. function HNLink(linkEl, metaEl) {
  126. var subtext = metaEl.querySelector('td.subtext')
  127. var commentLink = [...subtext.querySelectorAll('a[href^=item]')].pop()
  128.  
  129. // Job posts can't have comments
  130. this.isCommentable = (commentLink != null)
  131. if (!this.isCommentable) { return }
  132. this.id = commentLink.href.split('=').pop()
  133. this.commentCount = (/^\d+/.test(commentLink.textContent)
  134. ? Number(commentLink.textContent.split(' ').shift())
  135. : null)
  136. this.lastCommentCount = null
  137.  
  138. this.els = {
  139. link: linkEl
  140. , meta: metaEl
  141. , subtext: subtext
  142. }
  143. }
  144.  
  145. HNLink.prototype.initDOM = function() {
  146. if (!this.isCommentable) {
  147. return
  148. }
  149. if (this.commentCount != null &&
  150. this.lastCommentCount != null &&
  151. this.commentCount > this.lastCommentCount) {
  152. var newCommentCount = this.commentCount - this.lastCommentCount
  153. this.els.subtext.appendChild($el('span', null
  154. , ' ('
  155. , $el('a', {href: '/item?shownew&id=' + this.id, style: {fontWeight: 'bold'}}
  156. , newCommentCount
  157. , ' new'
  158. )
  159. , ')'
  160. ))
  161. }
  162. }
  163.  
  164. // ================================================================ HNComment ==
  165.  
  166. /**
  167. * @param {Element} el the DOM element wrapping the entire comment.
  168. * @param {Number} index the index of the comment in the list of comments.
  169. * @param {Number} lastMaxCommentId the max comment id on the previous visit,
  170. * should be falsy if there was none.
  171. */
  172. function HNComment(el, index, lastMaxCommentId) {
  173. var topBar = el.querySelector('td.default > div')
  174. var comment = el.querySelector('span.comment')
  175. var isDeleted = /^\s*\[\w+\]\s*$/.test(comment.firstChild.nodeValue)
  176.  
  177. this.id = (!isDeleted ? Number(topBar.querySelector('a[href^=item]').href.split('=').pop()) : -1)
  178. this.index = index
  179. this.indent = Number(el.querySelector('img[src="s.gif"]').width)
  180.  
  181. this.isCollapsed = false
  182. this.isDeleted = isDeleted
  183. this.isNew = (!!lastMaxCommentId && this.id > lastMaxCommentId)
  184. this.isTopLevel = (this.indent === 0)
  185.  
  186. this.els = {
  187. wrapper: el
  188. , topBar: topBar
  189. , vote: el.querySelector('td[valign="top"] > center')
  190. , comment: comment
  191. , reply: el.querySelector('span.comment + div.reply')
  192. , toggleControl: $el('span', {
  193. style: {cursor: 'pointer'}
  194. , onClick: function() { this.toggleCollapsed() }.bind(this)
  195. }, '[–]')
  196. }
  197. }
  198.  
  199. HNComment.prototype.addToggleControlToDOM = function() {
  200. // We want to use the comment metadata bar for the toggle control, so put it
  201. // back above the [deleted] placeholder.
  202. if (this.isDeleted) {
  203. this.els.topBar.style.marginBottom = '4px';
  204. }
  205. var el = this.els.topBar
  206. el.insertBefore(document.createTextNode(' '), el.firstChild)
  207. el.insertBefore(this.els.toggleControl, el.firstChild)
  208. }
  209.  
  210. /**
  211. * Cached getter for child comments - that is, any comments immediately
  212. * following this one which have a larger indent.
  213. */
  214. HNComment.prototype.children = function() {
  215. if (typeof this._children == 'undefined') {
  216. this._children = []
  217. for (var i = this.index + 1, l = comments.length; i < l; i++) {
  218. var child = comments[i]
  219. if (child.indent <= this.indent) { break }
  220. this._children.push(child)
  221. }
  222. }
  223. return this._children
  224. }
  225.  
  226. /**
  227. * Cached getter for determining if this comment has child comments which are
  228. * new since the last visit to the page.
  229. */
  230. HNComment.prototype.hasNewComments = function() {
  231. if (typeof this._hasNewComments == 'undefined') {
  232. var children = this.children(comments)
  233. var foundNewComment = false
  234. for (var i = 0, l = children.length; i < l; i++) {
  235. if (children[i].isNew) {
  236. foundNewComment = true
  237. break
  238. }
  239. }
  240. this._hasNewComments = foundNewComment
  241. }
  242. return this._hasNewComments
  243. }
  244.  
  245. /**
  246. * If given a new collapse state, applies it. Otherwise toggles the current
  247. * collapsed state.
  248. * @param {Boolean=} collapse.
  249. */
  250. HNComment.prototype.toggleCollapsed = function(collapse) {
  251. if (arguments.length === 0) {
  252. collapse = !this.isCollapsed
  253. }
  254. this._updateDOMCollapsed(!collapse)
  255. this.isCollapsed = collapse
  256. }
  257.  
  258. HNComment.prototype.toggleHighlighted = function(highlight) {
  259. this.els.wrapper.style.backgroundColor = (highlight ? '#ffffde' : 'transparent')
  260. }
  261.  
  262. /**
  263. * @param {Boolean} show.
  264. */
  265. HNComment.prototype._updateDOMCollapsed = function(show) {
  266. toggleDisplay(this.els.comment, show)
  267. if (this.els.reply) {
  268. toggleDisplay(this.els.reply, show)
  269. }
  270. if (this.els.vote) {
  271. this.els.vote.style.visibility = (show ? 'visible' : 'hidden')
  272. }
  273. this.els.toggleControl.textContent = (show ? '[–]' : '[+]')
  274. var children = this.children()
  275. children.forEach(function(child) {
  276. toggleDisplay(child.els.wrapper, show)
  277. })
  278. if (show) {
  279. this.els.topBar.removeChild(this.els.topBar.lastChild)
  280. }
  281. else {
  282. this.els.topBar.appendChild(document.createTextNode(
  283. (this.isDeleted ? '(' : ' | (') + children.length +
  284. ' child' + pluralise(children.length, 'ren') + ')'
  285. ))
  286. }
  287. }
  288.  
  289. var links = []
  290. var comments = []
  291.  
  292. function linkPage() {
  293. var linkNodes = document.evaluate('//tr[@class="athing"]', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
  294. for (var i = 0, l = linkNodes.snapshotLength; i < l; i++) {
  295. var linkNode = linkNodes.snapshotItem(i)
  296. var metaNode = linkNode.nextElementSibling
  297. var link = new HNLink(linkNode, metaNode)
  298. var lastCommentCount = getData(link.id + COMMENT_COUNT_KEY, null)
  299. if (lastCommentCount != null) {
  300. link.lastCommentCount = Number(lastCommentCount)
  301. }
  302. links.push(link)
  303. }
  304.  
  305. links.forEach(function(link) {
  306. link.initDOM()
  307. })
  308. }
  309.  
  310. function commentPage() {
  311. LOG('>>> commentPage')
  312. // Hide new built-in comment toggling
  313. GM_addStyle('a.togg { display: none; }')
  314.  
  315. var itemId = location.search.split('=').pop()
  316. var maxCommentIdKey = itemId + MAX_COMMENT_ID_KEY
  317. var lastVisitKey = itemId + LAST_VISIT_TIME_KEY
  318. var lastMaxCommentId = Number(getData(maxCommentIdKey, '0'))
  319. var lastVisit = getData(lastVisitKey, null)
  320. if (typeof lastVisit != 'undefined') {
  321. lastVisit = new Date(Number(lastVisit))
  322. }
  323. var maxCommentId = -1
  324. var newCommentCount = 0
  325. LOG({itemId, maxCommentIdKey, lastVisitKey, lastMaxCommentId, lastVisit})
  326.  
  327. var commentNodes = document.evaluate('//table[@class="comment-tree"]//tr[contains(@class,"athing")]', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
  328. LOG('commentNodes.snapshotLength', commentNodes.snapshotLength)
  329.  
  330. for (var i = 0, l = commentNodes.snapshotLength; i < l; i++) {
  331. var wrapper = commentNodes.snapshotItem(i)
  332. if (wrapper.style.height == '10px') {
  333. // This is a spacer row prior to a "more" link, so we've reached the end of
  334. // the comments.
  335. break
  336. }
  337. var comment = new HNComment(wrapper, i, lastMaxCommentId)
  338. if (comment.id > maxCommentId) {
  339. maxCommentId = comment.id
  340. }
  341. if (comment.isNew) {
  342. newCommentCount++
  343. }
  344. comments.push(comment)
  345. }
  346. LOG({maxCommentId, newCommentCount})
  347.  
  348. function highlightNewComments(highlight) {
  349. comments.forEach(function(comment) {
  350. if (comment.isNew) {
  351. comment.toggleHighlighted(highlight)
  352. }
  353. })
  354. }
  355.  
  356. function collapseThreadsWithoutNewComments(collapse) {
  357. for (var i = 0, l = comments.length; i < l; i++) {
  358. var comment = comments[i]
  359. if (!comment.isNew && !comment.hasNewComments(comments)) {
  360. comment.toggleCollapsed(collapse)
  361. i += comment.children(comments).length
  362. }
  363. }
  364. }
  365.  
  366. var highlightNew = (location.search.indexOf('?shownew') != -1)
  367.  
  368. comments.forEach(function(comment) {
  369. comment.addToggleControlToDOM()
  370. })
  371.  
  372. var commentCount
  373. if (location.pathname == '/item') {
  374. var commentsLink = document.querySelector('td.subtext > a[href^=item]')
  375. if (commentsLink && /^\d+/.test(commentsLink.textContent)) {
  376. commentCount = commentsLink.textContent.split(' ').shift()
  377. }
  378. }
  379.  
  380. if (lastVisit && newCommentCount > 0) {
  381. var el = (document.querySelector('form[action="/r"]') ||
  382. document.querySelector('td.subtext'))
  383. if (el) {
  384. el.appendChild($el('div', null
  385. , $el('p', null
  386. , (newCommentCount + ' new comment' + pluralise(newCommentCount) +
  387. ' since ' + lastVisit.toLocaleString())
  388. )
  389. , $el('div', null
  390. , $checkboxControl('highlight new comments', highlightNew, function() {
  391. highlightNewComments(this.checked)
  392. })
  393. , ' '
  394. , $checkboxControl('collapse threads without new comments', highlightNew, function() {
  395. collapseThreadsWithoutNewComments(this.checked)
  396. })
  397. )
  398. ))
  399. }
  400.  
  401. if (highlightNew) {
  402. highlightNewComments(true)
  403. collapseThreadsWithoutNewComments(true)
  404. }
  405. LOG('<<< commentPage')
  406. }
  407.  
  408. if (location.pathname == '/item') {
  409. if (maxCommentId > lastMaxCommentId) {
  410. setData(maxCommentIdKey, ''+maxCommentId)
  411. }
  412. setData(lastVisitKey, ''+(new Date().getTime()))
  413. if (commentCount) {
  414. setData(itemId + COMMENT_COUNT_KEY, commentsLink.textContent.split(' ').shift())
  415. }
  416. }
  417. LOG('<<< commentPage')
  418. }
  419.  
  420. // Initialise pagetype-specific enhancments
  421. void function() {
  422. var path = location.pathname.slice(1)
  423. if (/^(?:$|active|ask|best|news|newest|noobstories|show|submitted|upvoted)/.test(path)) { return linkPage }
  424. if (/^item/.test(path)) { return commentPage }
  425. if (/^x/.test(path)) { return (document.title.indexOf('more comments') == 0 ? commentPage : linkPage) }
  426. return function() {}
  427. }()()
  428.  
  429. // Add an "upvoted" link to the top bar
  430. if (window.location.pathname !== '/upvoted') {
  431. var userName = document.querySelector('span.pagetop a[href^="user?id"]').textContent
  432. var pageTop = document.querySelector('span.pagetop')
  433. pageTop.appendChild(document.createTextNode(' | '))
  434. pageTop.appendChild($el('a', {href: '/upvoted?id=' + userName}, 'upvoted'))
  435. }