HN Comment Trees

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

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