HN Comment Trees

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

当前为 2019-08-17 提交的版本,查看 最新版本

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