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 38
  7. // ==/UserScript==
  8. const HIGHLIGHT_COLOR = '#ffffde'
  9. const TOGGLE_HIDE = '[–]'
  10. const TOGGLE_SHOW = '[+]'
  11.  
  12. let config = {
  13. addUpvotedToHeader: true,
  14. autoHighlightNew: true,
  15. hideReplyLinks: false,
  16. }
  17.  
  18. config.enableDebugLogging = false
  19.  
  20. //#region Storage
  21. class Visit {
  22. constructor({commentCount, maxCommentId, time}) {
  23. /** @type {number} */
  24. this.commentCount = commentCount
  25. /** @type {number} */
  26. this.maxCommentId = maxCommentId
  27. /** @type {Date} */
  28. this.time = time
  29. }
  30.  
  31. toJSON() {
  32. return {
  33. c: this.commentCount,
  34. m: this.maxCommentId,
  35. t: this.time.getTime(),
  36. }
  37. }
  38. }
  39.  
  40. Visit.fromJSON = function(obj) {
  41. return new Visit({
  42. commentCount: obj.c,
  43. maxCommentId: obj.m,
  44. time: new Date(obj.t),
  45. })
  46. }
  47.  
  48. function getLastVisit(itemId) {
  49. let json = localStorage.getItem(itemId)
  50. if (json == null) {
  51. return null
  52. }
  53. return Visit.fromJSON(JSON.parse(json))
  54. }
  55.  
  56. function storeVisit(itemId, visit) {
  57. log('storing visit', visit)
  58. localStorage.setItem(itemId, JSON.stringify(visit))
  59. }
  60. //#endregion
  61.  
  62. //#region Utility functions
  63. function addStyle(css = '') {
  64. let $style = document.createElement('style')
  65. if (css) {
  66. $style.textContent = css
  67. }
  68. document.querySelector('head').appendChild($style)
  69. return $style
  70. }
  71.  
  72. function checkbox(attributes, label) {
  73. return h('label', null,
  74. h('input', {
  75. style: {verticalAlign: 'middle'},
  76. type: 'checkbox',
  77. ...attributes,
  78. }),
  79. ' ',
  80. label,
  81. )
  82. }
  83.  
  84. function h(tagName, attributes, ...children) {
  85. let $el = document.createElement(tagName)
  86.  
  87. if (attributes) {
  88. for (let [prop, value] of Object.entries(attributes)) {
  89. if (prop.indexOf('on') === 0) {
  90. $el.addEventListener(prop.slice(2).toLowerCase(), value)
  91. }
  92. else if (prop.toLowerCase() == 'style') {
  93. for (let [styleProp, styleValue] of Object.entries(value)) {
  94. $el.style[styleProp] = styleValue
  95. }
  96. }
  97. else {
  98. $el[prop] = value
  99. }
  100. }
  101. }
  102.  
  103. for (let child of children) {
  104. if (child == null || child === false) {
  105. continue
  106. }
  107. if (child instanceof Node) {
  108. $el.appendChild(child)
  109. }
  110. else {
  111. $el.insertAdjacentText('beforeend', String(child))
  112. }
  113. }
  114.  
  115. return $el
  116. }
  117.  
  118. function log(...args) {
  119. if (config.enableDebugLogging) {
  120. console.log('🦉', ...args)
  121. }
  122. }
  123.  
  124. function s(count, suffixes = ',s') {
  125. if (!suffixes.includes(',')) {
  126. suffixes = `,${suffixes}`
  127. }
  128. return suffixes.split(',')[count === 1 ? 0 : 1]
  129. }
  130.  
  131. function toggleDisplay($el, hidden) {
  132. $el.classList.toggle('noshow', hidden)
  133. // We need to enforce display setting as the page's own script expands all
  134. // comments on page load.
  135. $el.style.display = hidden ? 'none' : ''
  136. }
  137.  
  138. function toggleVisibility($el, hidden) {
  139. $el.classList.toggle('nosee', hidden)
  140. // We need to enforce visibility setting as the page's own script expands
  141. // all comments on page load.
  142. $el.style.visibility = hidden ? 'hidden' : 'visible'
  143. }
  144. //#endregion
  145.  
  146. //#region Feature: add upvoted link to header
  147. function addUpvotedLinkToHeader() {
  148. if (window.location.pathname == '/upvoted') {
  149. return
  150. }
  151.  
  152. let $userLink = document.querySelector('span.pagetop a[href^="user?id"]')
  153. if (!$userLink) {
  154. return
  155. }
  156.  
  157. let $pageTop = document.querySelector('span.pagetop')
  158. $pageTop.insertAdjacentText('beforeend', ' | ')
  159. $pageTop.appendChild(h('a', {
  160. href: `/upvoted?id=${$userLink.textContent}`,
  161. }, 'upvoted'))
  162. }
  163. //#endregion
  164.  
  165. //#region Feature: new comment highlighting on comment pages
  166. /**
  167. * Each comment on a comment page has the following structure:
  168. *
  169. * ```html
  170. * <tr class="athing"> (wrapper)
  171. * <td>
  172. * <table>
  173. * <tr>
  174. * <td class="ind">
  175. * <img src="s.gif" height="1" width="123"> (indentation)
  176. * </td>
  177. * <td class="votelinks">…</td> (vote up/down controls)
  178. * <td class="default">
  179. * <div> (meta bar: user, age and folding control)
  180. * …
  181. * <div class="comment">
  182. * <span class="comtext"> (text and reply link)
  183. * ```
  184. *
  185. * We want to be able to collapse comment trees which don't contain new comments
  186. * and highlight new comments, so for each wrapper we'll create a `HNComment`
  187. * object to manage this.
  188. *
  189. * Comments are rendered as a flat list of table rows, so we'll use the width of
  190. * the indentation spacer to determine which comments are descendants of a given
  191. * comment.
  192. *
  193. * Since we have to reimplement our own comment folding, we'll hide the built-in
  194. * folding controls and create new ones in a better position (on the left), with
  195. * a larger hitbox (larger font and an en dash [–] instead of a hyphen [-]).
  196. *
  197. * On each comment page view, we store the current comment count, the max
  198. * comment id on the page and the current time as the last visit time.
  199. */
  200. function commentPage() {
  201. log('comment page')
  202.  
  203. /** @type {boolean} */
  204. let autoHighlightNew = config.autoHighlightNew || location.search.includes('?shownew')
  205.  
  206. /** @type {number} */
  207. let commentCount = 0
  208.  
  209. /** @type {HNComment[]} */
  210. let comments = []
  211.  
  212. /** @type {Object.<string, HNComment>} */
  213. let commentsById = {}
  214.  
  215. /** @type {boolean} */
  216. let hasNewComments = false
  217.  
  218. /** @type {string} */
  219. let itemId = /id=(\d+)/.exec(location.search)[1]
  220.  
  221. /** @type {Visit} */
  222. let lastVisit
  223.  
  224. /** @type {number} */
  225. let maxCommentId = -1
  226.  
  227. /** @type {number} */
  228. let newCommentCount = 0
  229.  
  230. class HNComment {
  231. /**
  232. * @param $wrapper {Element}
  233. * @param index {number}
  234. */
  235. constructor($wrapper, index) {
  236. /** @type {number} */
  237. this.indent = Number($wrapper.querySelector('img[src="s.gif"]').width)
  238.  
  239. /** @type {number} */
  240. this.index = index
  241.  
  242. /** @type {Element} */
  243. this.$comment = $wrapper.querySelector('div.comment')
  244.  
  245. /** @type {Element} */
  246. this.$topBar = $wrapper.querySelector('td.default > div')
  247.  
  248. /** @type {Element} */
  249. this.$vote = $wrapper.querySelector('td[valign="top"] > center')
  250.  
  251. /** @type {Element} */
  252. this.$wrapper = $wrapper
  253.  
  254. /** @private @type {HNComment[]} */
  255. this._childComments = null
  256.  
  257. /**
  258. * The comment's id.
  259. * Will be `-1` for deleted comments.
  260. * @type {number}
  261. */
  262. this.id = -1
  263.  
  264. /**
  265. * Some flagged comments are collapsed by default.
  266. * @type {boolean}
  267. */
  268. this.isCollapsed = $wrapper.classList.contains('coll')
  269.  
  270. /**
  271. * Comments whose text has been removed but are still displayed may have
  272. * their text replaced with [flagged], [dead] or similar - we'll take any
  273. * word in square brackets as indication of this.
  274. * @type {boolean}
  275. */
  276. this.isDeleted = /^\s*\[\w+]\s*$/.test(this.$comment.firstChild.nodeValue)
  277.  
  278. /**
  279. * The displayed age of the comment; `${n} minutes/hours/days ago`, or
  280. * `on ${date}` for older comments.
  281. * Will be blank for deleted comments.
  282. * @type {string}
  283. */
  284. this.when = ''
  285.  
  286. /** @type {Element} */
  287. this.$collapsedChildCount = null
  288.  
  289. /** @type {Element} */
  290. this.$comhead = this.$topBar.querySelector('span.comhead')
  291.  
  292. /** @type {Element} */
  293. this.$toggleControl = h('span', {
  294. onclick: () => this.toggleCollapsed(),
  295. style: {cursor: 'pointer'},
  296. }, this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE)
  297.  
  298. if (!this.isDeleted) {
  299. let $permalink = this.$topBar.querySelector('a[href^=item]')
  300. this.id = Number($permalink.href.split('=').pop())
  301. this.when = $permalink.textContent
  302. }
  303.  
  304. this.initDOM()
  305. }
  306.  
  307. initDOM() {
  308. // We want to use the comment meta bar for the folding control, so put
  309. // it back above the deleted comment placeholder.
  310. if (this.isDeleted) {
  311. this.$topBar.style.marginBottom = '4px'
  312. }
  313. if (this.isCollapsed) {
  314. this._updateDisplay(false)
  315. }
  316. this.$topBar.insertAdjacentText('afterbegin', ' ')
  317. this.$topBar.insertAdjacentElement('afterbegin', this.$toggleControl)
  318. }
  319.  
  320. /**
  321. * @private
  322. * @param updateChildren {boolean=}
  323. */
  324. _updateDisplay(updateChildren = true) {
  325. // Show/hide this comment, preserving display of the meta bar
  326. toggleDisplay(this.$comment, this.isCollapsed)
  327. if (this.$vote) {
  328. toggleVisibility(this.$vote, this.isCollapsed)
  329. }
  330. this.$toggleControl.textContent = this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE
  331.  
  332. // Show/hide the number of child comments when collapsed
  333. if (this.isCollapsed && this.$collapsedChildCount == null) {
  334. let collapsedCommentCount = [
  335. this.isDeleted ? '(' : ' | (',
  336. this.childComments.length,
  337. ` child${s(this.childComments.length, 'ren')})`,
  338. ].join('')
  339. this.$collapsedChildCount = h('span', null, collapsedCommentCount)
  340. this.$comhead.appendChild(this.$collapsedChildCount)
  341. }
  342. toggleDisplay(this.$collapsedChildCount, !this.isCollapsed)
  343.  
  344. // Completely show/hide any child comments
  345. if (updateChildren) {
  346. this.childComments.forEach((child) => toggleDisplay(child.$wrapper, this.isCollapsed))
  347. }
  348. }
  349.  
  350. /**
  351. * @returns {HNComment[]}
  352. */
  353. get childComments() {
  354. if (this._childComments == null) {
  355. this._childComments = []
  356. for (let i = this.index + 1; i < comments.length; i++) {
  357. if (comments[i].indent <= this.indent) {
  358. break
  359. }
  360. this._childComments.push(comments[i])
  361. }
  362. }
  363. return this._childComments
  364. }
  365.  
  366. /**
  367. * @param commentId {number}
  368. * @returns {boolean}
  369. */
  370. hasChildCommentsNewerThan(commentId) {
  371. return this.childComments.some((comment) => comment.isNewerThan(commentId))
  372. }
  373.  
  374. /**
  375. * @param commentId {number}
  376. * @returns {boolean}
  377. */
  378. isNewerThan(commentId) {
  379. return this.id > commentId
  380. }
  381.  
  382. /**
  383. * @param isCollapsed {boolean=}
  384. */
  385. toggleCollapsed(isCollapsed = !this.isCollapsed) {
  386. this.isCollapsed = isCollapsed
  387. this._updateDisplay()
  388. }
  389.  
  390. /**
  391. * @param highlight {boolean}
  392. */
  393. toggleHighlighted(highlight) {
  394. this.$wrapper.style.backgroundColor = highlight ? HIGHLIGHT_COLOR : 'transparent'
  395. }
  396. }
  397.  
  398. /**
  399. * Adds checkboxes to toggle folding and highlighting when there are new
  400. * comments on a comment page.
  401. * @param $container {Element}
  402. */
  403. function addNewCommentControls($container) {
  404. $container.appendChild(
  405. h('div', null,
  406. h('p', null,
  407. `${newCommentCount} new comment${s(newCommentCount)} since ${lastVisit.time.toLocaleString()}`
  408. ),
  409. h('div', null,
  410. checkbox({
  411. checked: autoHighlightNew,
  412. onclick: (e) => {
  413. highlightNewComments(e.target.checked, lastVisit.maxCommentId)
  414. },
  415. }, 'highlight new comments'),
  416. ' ',
  417. checkbox({
  418. checked: autoHighlightNew,
  419. onclick: (e) => {
  420. collapseThreadsWithoutNewComments(e.target.checked, lastVisit.maxCommentId)
  421. },
  422. }, 'collapse threads without new comments'),
  423. ),
  424. )
  425. )
  426. }
  427.  
  428. /**
  429. * Adds a range control and button to show the last X new comments.
  430. */
  431. function addTimeTravelCommentControls($container) {
  432. let sortedCommentIds = comments.map((comment) => comment.id)
  433. .filter(id => id !== -1)
  434. .sort((a, b) => a - b)
  435.  
  436. let showNewCommentsAfter = Math.max(0, sortedCommentIds.length - 1)
  437. let howMany = sortedCommentIds.length - showNewCommentsAfter
  438.  
  439. function getButtonLabel() {
  440. let fromWhen = commentsById[sortedCommentIds[showNewCommentsAfter]].when
  441. // Older comments display `on ${date}` instead of a relative time
  442. if (fromWhen.startsWith(' on')) {
  443. fromWhen = fromWhen.replace(' on', 'since')
  444. }
  445. else {
  446. fromWhen = `from ${fromWhen}`
  447. }
  448. return `highlight ${howMany} comment${s(howMany)} ${fromWhen}`
  449. }
  450.  
  451. let $range = h('input', {
  452. max: sortedCommentIds.length - 1,
  453. min: 1,
  454. oninput(e) {
  455. showNewCommentsAfter = Number(e.target.value)
  456. howMany = sortedCommentIds.length - showNewCommentsAfter
  457. $button.value = getButtonLabel()
  458. },
  459. style: {margin: 0, verticalAlign: 'middle'},
  460. type: 'range',
  461. value: sortedCommentIds.length - 1,
  462. })
  463.  
  464. let $button = h('input', {
  465. onclick() {
  466. let referenceCommentId = sortedCommentIds[showNewCommentsAfter - 1]
  467. log(`manually highlighting ${howMany} comments since ${referenceCommentId}`)
  468. highlightNewComments(true, referenceCommentId)
  469. collapseThreadsWithoutNewComments(true, referenceCommentId)
  470. $timeTravelControl.remove()
  471. },
  472. type: 'button',
  473. value: getButtonLabel(),
  474. })
  475.  
  476. let $timeTravelControl = h('div', {
  477. style: {marginTop: '1em'},
  478. }, $range, ' ', $button)
  479.  
  480. $container.appendChild($timeTravelControl)
  481. }
  482.  
  483. /**
  484. * Adds the appropriate page controls depending on whether or not there are
  485. * new comments or any comments at all.
  486. */
  487. function addPageControls() {
  488. let $container = document.querySelector('td.subtext')
  489. if (!$container) {
  490. log('no container found for page controls')
  491. return
  492. }
  493.  
  494. if (hasNewComments) {
  495. addNewCommentControls($container)
  496. }
  497. else if (commentCount > 1) {
  498. addTimeTravelCommentControls($container)
  499. }
  500. }
  501.  
  502. /**
  503. * Collapses threads which don't have any comments newer than the given
  504. * comment id.
  505. * @param collapse {boolean}
  506. * @param referenceCommentId {number}
  507. */
  508. function collapseThreadsWithoutNewComments(collapse, referenceCommentId) {
  509. for (let i = 0; i < comments.length; i++) {
  510. let comment = comments[i]
  511. if (!comment.isNewerThan(referenceCommentId) &&
  512. !comment.hasChildCommentsNewerThan(referenceCommentId)) {
  513. comment.toggleCollapsed(collapse)
  514. // Skip over child comments
  515. i += comment.childComments.length
  516. }
  517. }
  518. }
  519.  
  520. function hideBuiltInCommentFoldingControls() {
  521. addStyle('a.togg { display: none; }')
  522. }
  523.  
  524. let toggleHideReplyLinks = (function() {
  525. let $style = addStyle()
  526. return () => {
  527. $style.textContent = config.hideReplyLinks ? `
  528. div.reply { margin-top: 8px; }
  529. div.reply p { display: none; }
  530. ` : ''
  531. }
  532. })()
  533.  
  534. /**
  535. * Highlights comments newer than the given comment id.
  536. * @param highlight {boolean}
  537. * @param referenceCommentId {number}
  538. */
  539. function highlightNewComments(highlight, referenceCommentId) {
  540. comments.forEach((comment) => {
  541. if (comment.isNewerThan(referenceCommentId)) {
  542. comment.toggleHighlighted(highlight)
  543. }
  544. })
  545. }
  546.  
  547. function initComments() {
  548. let commentWrappers = document.querySelectorAll('table.comment-tree tr.athing')
  549. log('number of comment wrappers', commentWrappers.length)
  550. let index = 0
  551. let lastMaxCommentId = lastVisit != null ? lastVisit.maxCommentId : -1
  552. for (let $wrapper of commentWrappers) {
  553. let comment = new HNComment($wrapper, index++)
  554. if (comment.id > maxCommentId) {
  555. maxCommentId = comment.id
  556. }
  557. if (comment.isNewerThan(lastMaxCommentId)) {
  558. newCommentCount++
  559. }
  560. comments.push(comment)
  561. if (comment.id != -1) {
  562. commentsById[comment.id] = comment
  563. }
  564. }
  565. hasNewComments = lastVisit != null && newCommentCount > 0
  566. }
  567.  
  568. // TODO Only store visit data when the item header is present (i.e. not a comment permalink)
  569. // TODO Only store visit data for commentable items (a reply box / reply links are visible)
  570. // TODO Clear any existing stored visit if the item is no longer commentable
  571. function storePageViewData() {
  572. storeVisit(itemId, new Visit({
  573. commentCount,
  574. maxCommentId,
  575. time: new Date(),
  576. }))
  577. }
  578.  
  579. lastVisit = getLastVisit(itemId)
  580.  
  581. let $commentsLink = document.querySelector('td.subtext > a[href^=item]')
  582. if ($commentsLink && /^\d+/.test($commentsLink.textContent)) {
  583. commentCount = Number($commentsLink.textContent.split(/\s/).shift())
  584. }
  585.  
  586. hideBuiltInCommentFoldingControls()
  587. toggleHideReplyLinks()
  588. initComments()
  589. if (hasNewComments && autoHighlightNew) {
  590. highlightNewComments(true, lastVisit.maxCommentId)
  591. collapseThreadsWithoutNewComments(true, lastVisit.maxCommentId)
  592. }
  593. addPageControls()
  594. storePageViewData()
  595.  
  596. log('page view data', {
  597. autoHighlightNew,
  598. commentCount,
  599. hasNewComments,
  600. itemId,
  601. lastVisit,
  602. maxCommentId,
  603. newCommentCount,
  604. })
  605. }
  606. //#endregion
  607.  
  608. //#region Feature: new comment indicators on link pages
  609. /**
  610. * Each item on an item list page has the following structure:
  611. *
  612. * ```html
  613. * <tr class="athing">…</td> (rank, upvote control, title/link and domain)
  614. * <tr>
  615. * <td>…</td> (spacer)
  616. * <td class="subtext">…</td> (item meta info)
  617. * </tr>
  618. * <tr class="spacer">…</tr>
  619. * ```
  620. *
  621. * Using the comment count stored when you visit a comment page, we'll display
  622. * the number of new comments in the subtext section and provide a link which
  623. * will automatically highlight new comments and collapse comment trees without
  624. * new comments.
  625. *
  626. * For regular stories, the subtext element contains points, user, age (in
  627. * a link to the comments page), flag/hide controls and finally the number of
  628. * comments (in another link to the comments page). We'll look for the latter
  629. * to detemine the current number of comments and the item id.
  630. *
  631. * For job postings, the subtext element only contains age (in
  632. * a link to the comments page) and a hide control, so we'll try to ignore
  633. * those.
  634. */
  635. function itemListPage() {
  636. log('item list page')
  637.  
  638. let commentLinks = document.querySelectorAll('td.subtext > a[href^="item?id="]:last-child')
  639. log('number of comments/discuss links', commentLinks.length)
  640.  
  641. let noCommentsCount = 0
  642. let noLastVisitCount = 0
  643.  
  644. for (let $commentLink of commentLinks) {
  645. let id = $commentLink.href.split('=').pop()
  646.  
  647. let commentCountMatch = /^(\d+)/.exec($commentLink.textContent)
  648. if (commentCountMatch == null) {
  649. noCommentsCount++
  650. continue
  651. }
  652.  
  653. let lastVisit = getLastVisit(id)
  654. if (lastVisit == null) {
  655. noLastVisitCount++
  656. continue
  657. }
  658.  
  659. let commentCount = Number(commentCountMatch[1])
  660. if (commentCount <= lastVisit.commentCount) {
  661. log(`${id} doesn't have any new comments`, lastVisit)
  662. continue
  663. }
  664.  
  665. $commentLink.insertAdjacentElement('afterend',
  666. h('span', null,
  667. ' (',
  668. h('a', {
  669. href: `/item?shownew&id=${id}`,
  670. style: {fontWeight: 'bold'},
  671. },
  672. commentCount - lastVisit.commentCount,
  673. ' new'
  674. ),
  675. ')',
  676. )
  677. )
  678. }
  679.  
  680. if (noCommentsCount > 0) {
  681. log(`${noCommentsCount} item${s(noCommentsCount, " doesn't,s don't")} have any comments`)
  682. }
  683. if (noLastVisitCount > 0) {
  684. log(`${noLastVisitCount} item${s(noLastVisitCount, " doesn't,s don't")} have a last visit stored`)
  685. }
  686. }
  687. //#endregion
  688.  
  689. //#region Main
  690. function main() {
  691. log('config', config)
  692.  
  693. if (config.addUpvotedToHeader) {
  694. addUpvotedLinkToHeader()
  695. }
  696.  
  697. let page
  698. let path = location.pathname.slice(1)
  699.  
  700. if (/^($|active|ask|best|front|news|newest|noobstories|show|submitted|upvoted)/.test(path)) {
  701. page = itemListPage
  702. }
  703. else if (/^item/.test(path)) {
  704. page = commentPage
  705. }
  706.  
  707. if (page) {
  708. page()
  709. }
  710. }
  711.  
  712. main()
  713. //#endregion