HN Comments Owl

Highlight new Hacker News comments, mute users and other UX tweaks

目前为 2022-02-11 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name HN Comments Owl
  3. // @description Highlight new Hacker News comments, mute users and other UX tweaks
  4. // @namespace https://github.com/insin/hn-comments-owl/
  5. // @match https://news.ycombinator.com/*
  6. // @version 40
  7. // ==/UserScript==
  8. const enableDebugLogging = false
  9.  
  10. const HIGHLIGHT_COLOR = '#ffffde'
  11. const TOGGLE_HIDE = '[–]'
  12. const TOGGLE_SHOW = '[+]'
  13.  
  14. /** @type {import("./types").Config} */
  15. let config = {
  16. addUpvotedToHeader: true,
  17. autoHighlightNew: true,
  18. hideReplyLinks: false,
  19. }
  20.  
  21. //#region Storage
  22. class Visit {
  23. constructor({commentCount, maxCommentId, time}) {
  24. /** @type {number} */
  25. this.commentCount = commentCount
  26. /** @type {number} */
  27. this.maxCommentId = maxCommentId
  28. /** @type {Date} */
  29. this.time = time
  30. }
  31.  
  32. toJSON() {
  33. return {
  34. c: this.commentCount,
  35. m: this.maxCommentId,
  36. t: this.time.getTime(),
  37. }
  38. }
  39. }
  40.  
  41. Visit.fromJSON = function(obj) {
  42. return new Visit({
  43. commentCount: obj.c,
  44. maxCommentId: obj.m,
  45. time: new Date(obj.t),
  46. })
  47. }
  48.  
  49. function getLastVisit(itemId) {
  50. let json = localStorage.getItem(itemId)
  51. if (json == null) return null
  52. return Visit.fromJSON(JSON.parse(json))
  53. }
  54.  
  55. function storeVisit(itemId, visit) {
  56. log('storing visit', visit)
  57. localStorage.setItem(itemId, JSON.stringify(visit))
  58. }
  59.  
  60. function getMutedUsers() {
  61. return new Set(JSON.parse(localStorage.mutedUsers || '[]'))
  62. }
  63.  
  64. function setMutedUsers(mutedUsers) {
  65. localStorage.mutedUsers = JSON.stringify(Array.from(mutedUsers))
  66. }
  67. //#endregion
  68.  
  69. //#region Utility functions
  70. function addStyle(css = '') {
  71. let $style = document.createElement('style')
  72. if (css) {
  73. $style.textContent = css
  74. }
  75. document.querySelector('head').appendChild($style)
  76. return $style
  77. }
  78.  
  79. function checkbox(attributes, label) {
  80. return h('label', null,
  81. h('input', {
  82. style: {verticalAlign: 'middle'},
  83. type: 'checkbox',
  84. ...attributes,
  85. }),
  86. ' ',
  87. label,
  88. )
  89. }
  90.  
  91. /**
  92. * Create an element.
  93. * @param {string} tagName
  94. * @param {{[key: string]: any}} [attributes]
  95. * @param {...any} children
  96. * @returns {HTMLElement}
  97. */
  98. function h(tagName, attributes, ...children) {
  99. let $el = document.createElement(tagName)
  100.  
  101. if (attributes) {
  102. for (let [prop, value] of Object.entries(attributes)) {
  103. if (prop.indexOf('on') === 0) {
  104. $el.addEventListener(prop.slice(2).toLowerCase(), value)
  105. }
  106. else if (prop.toLowerCase() == 'style') {
  107. for (let [styleProp, styleValue] of Object.entries(value)) {
  108. $el.style[styleProp] = styleValue
  109. }
  110. }
  111. else {
  112. $el[prop] = value
  113. }
  114. }
  115. }
  116.  
  117. for (let child of children) {
  118. if (child == null || child === false) {
  119. continue
  120. }
  121. if (child instanceof Node) {
  122. $el.appendChild(child)
  123. }
  124. else {
  125. $el.insertAdjacentText('beforeend', String(child))
  126. }
  127. }
  128.  
  129. return $el
  130. }
  131.  
  132. function log(...args) {
  133. if (enableDebugLogging) {
  134. console.log('🦉', ...args)
  135. }
  136. }
  137.  
  138. /**
  139. * @param {number} count
  140. * @param {string} suffixes
  141. * @returns {string}
  142. */
  143. function s(count, suffixes = ',s') {
  144. if (!suffixes.includes(',')) {
  145. suffixes = `,${suffixes}`
  146. }
  147. return suffixes.split(',')[count === 1 ? 0 : 1]
  148. }
  149.  
  150. /**
  151. * @param {HTMLElement} $el
  152. * @param {boolean} hidden
  153. */
  154. function toggleDisplay($el, hidden) {
  155. $el.classList.toggle('noshow', hidden)
  156. // We need to enforce display setting as the page's own script expands all
  157. // comments on page load.
  158. $el.style.display = hidden ? 'none' : ''
  159. }
  160.  
  161. /**
  162. * @param {HTMLElement} $el
  163. * @param {boolean} hidden
  164. */
  165. function toggleVisibility($el, hidden) {
  166. $el.classList.toggle('nosee', hidden)
  167. // We need to enforce visibility setting as the page's own script expands
  168. // all comments on page load.
  169. $el.style.visibility = hidden ? 'hidden' : 'visible'
  170. }
  171. //#endregion
  172.  
  173. //#region Feature: add upvoted link to header
  174. function addUpvotedLinkToHeader() {
  175. if (window.location.pathname == '/upvoted') return
  176.  
  177. let $userLink = document.querySelector('span.pagetop a[href^="user?id"]')
  178. if (!$userLink) return
  179.  
  180. let $pageTop = document.querySelector('span.pagetop')
  181. $pageTop.insertAdjacentText('beforeend', ' | ')
  182. $pageTop.appendChild(h('a', {
  183. href: `/upvoted?id=${$userLink.textContent}`,
  184. }, 'upvoted'))
  185. }
  186. //#endregion
  187.  
  188. //#region Feature: new comment highlighting on comment pages
  189. /**
  190. * Each comment on a comment page has the following structure:
  191. *
  192. * ```html
  193. * <tr class="athing"> (wrapper)
  194. * <td>
  195. * <table>
  196. * <tr>
  197. * <td class="ind">
  198. * <img src="s.gif" height="1" width="123"> (indentation)
  199. * </td>
  200. * <td class="votelinks">…</td> (vote up/down controls)
  201. * <td class="default">
  202. * <div style="margin-top:2px; margin-bottom:-10px;">
  203. * <div class="comhead"> (meta bar: user, age and folding control)
  204. * …
  205. * <div class="comment">
  206. * <span class="comtext"> (text and reply link)
  207. * ```
  208. *
  209. * We want to be able to collapse comment trees which don't contain new comments
  210. * and highlight new comments, so for each wrapper we'll create a `HNComment`
  211. * object to manage this.
  212. *
  213. * Comments are rendered as a flat list of table rows, so we'll use the width of
  214. * the indentation spacer to determine which comments are descendants of a given
  215. * comment.
  216. *
  217. * Since we have to reimplement our own comment folding, we'll hide the built-in
  218. * folding controls and create new ones in a better position (on the left), with
  219. * a larger hitbox (larger font and an en dash [–] instead of a hyphen [-]).
  220. *
  221. * On each comment page view, we store the current comment count, the max
  222. * comment id on the page and the current time as the last visit time.
  223. */
  224. function commentPage() {
  225. log('comment page')
  226. addStyle(`
  227. .mute {
  228. display: none;
  229. }
  230. tr.comtr:hover .mute {
  231. display: inline;
  232. }
  233. `)
  234.  
  235. /** @type {boolean} */
  236. let autoHighlightNew = config.autoHighlightNew || location.search.includes('?shownew')
  237.  
  238. /** @type {number} */
  239. let commentCount = 0
  240.  
  241. /** @type {HNComment[]} */
  242. let comments = []
  243.  
  244. /** @type {Object.<string, HNComment>} */
  245. let commentsById = {}
  246.  
  247. /** @type {boolean} */
  248. let hasNewComments = false
  249.  
  250. /** @type {string} */
  251. let itemId = /id=(\d+)/.exec(location.search)[1]
  252.  
  253. /** @type {Visit} */
  254. let lastVisit
  255.  
  256. /** @type {number} */
  257. let maxCommentId = -1
  258.  
  259. /** @type {number} */
  260. let newCommentCount = 0
  261.  
  262. /** @type {Set<string>} */
  263. let mutedUsers = getMutedUsers()
  264.  
  265. class HNComment {
  266. /**
  267. * returns {boolean}
  268. */
  269. get isMuted() {
  270. return mutedUsers.has(this.user)
  271. }
  272.  
  273. /**
  274. * @returns {HNComment[]}
  275. */
  276. get childComments() {
  277. if (this._childComments == null) {
  278. this._childComments = []
  279. for (let i = this.index + 1; i < comments.length; i++) {
  280. if (comments[i].indent <= this.indent) {
  281. break
  282. }
  283. this._childComments.push(comments[i])
  284. }
  285. }
  286. return this._childComments
  287. }
  288.  
  289. /**
  290. * @returns {HNComment[]}
  291. */
  292. get nonMutedChildComments() {
  293. if (this._nonMutedChildComments == null) {
  294. let muteIndent = null
  295. this._nonMutedChildComments = this.childComments.filter(comment => {
  296. if (muteIndent != null) {
  297. if (comment.indent > muteIndent) {
  298. return false
  299. }
  300. muteIndent = null
  301. }
  302.  
  303. if (comment.isMuted) {
  304. muteIndent = comment.indent
  305. return false
  306. }
  307.  
  308. return true
  309. })
  310. }
  311. return this._nonMutedChildComments
  312. }
  313.  
  314. /**
  315. * returns {number}
  316. */
  317. get childCommentCount() {
  318. return this.nonMutedChildComments.length
  319. }
  320.  
  321. /**
  322. * @param {HTMLElement} $wrapper
  323. * @param {number} index
  324. */
  325. constructor($wrapper, index) {
  326. /** @type {number} */
  327. this.indent = Number( /** @type {HTMLImageElement} */ ($wrapper.querySelector('img[src="s.gif"]')).width)
  328.  
  329. /** @type {number} */
  330. this.index = index
  331.  
  332. let $user = /** @type {HTMLElement} */ ($wrapper.querySelector('a.hnuser'))
  333. /** @type {string} */
  334. this.user = $user?.innerText
  335.  
  336. /** @type {HTMLElement} */
  337. this.$comment = $wrapper.querySelector('div.comment')
  338.  
  339. /** @type {HTMLElement} */
  340. this.$topBar = $wrapper.querySelector('td.default > div')
  341.  
  342. /** @type {HTMLElement} */
  343. this.$vote = $wrapper.querySelector('td[valign="top"] > center')
  344.  
  345. /** @type {HTMLElement} */
  346. this.$wrapper = $wrapper
  347.  
  348. /** @private @type {HNComment[]} */
  349. this._childComments = null
  350.  
  351. /** @private @type {HNComment[]} */
  352. this._nonMutedChildComments = null
  353.  
  354. /**
  355. * The comment's id.
  356. * Will be `-1` for deleted comments.
  357. * @type {number}
  358. */
  359. this.id = -1
  360.  
  361. /**
  362. * Some flagged comments are collapsed by default.
  363. * @type {boolean}
  364. */
  365. this.isCollapsed = $wrapper.classList.contains('coll')
  366.  
  367. /**
  368. * Comments whose text has been removed but are still displayed may have
  369. * their text replaced with [flagged], [dead] or similar - we'll take any
  370. * word in square brackets as indication of this.
  371. * @type {boolean}
  372. */
  373. this.isDeleted = /^\s*\[\w+]\s*$/.test(this.$comment.firstChild.nodeValue)
  374.  
  375. /**
  376. * The displayed age of the comment; `${n} minutes/hours/days ago`, or
  377. * `on ${date}` for older comments.
  378. * Will be blank for deleted comments.
  379. * @type {string}
  380. */
  381. this.when = ''
  382.  
  383. /** @type {HTMLElement} */
  384. this.$collapsedChildCount = null
  385.  
  386. /** @type {HTMLElement} */
  387. this.$comhead = this.$topBar.querySelector('span.comhead')
  388.  
  389. /** @type {HTMLElement} */
  390. this.$toggleControl = h('span', {
  391. onclick: () => this.toggleCollapsed(),
  392. style: {cursor: 'pointer'},
  393. }, this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE)
  394.  
  395. /** @type {HTMLElement} */
  396. this.$muteControl = h('span', {className: 'mute'}, ' | ', h('a', {
  397. href: `mute?id=${this.user}`,
  398. onclick: (e) => {
  399. e.preventDefault()
  400. this.mute()
  401. }
  402. }, 'mute'))
  403.  
  404. if (!this.isDeleted) {
  405. let $permalink = /** @type {HTMLAnchorElement} */ (this.$topBar.querySelector('a[href^=item]'))
  406. this.id = Number($permalink.href.split('=').pop())
  407. this.when = $permalink?.textContent.replace('minute', 'min')
  408. }
  409.  
  410. this.initDOM()
  411. }
  412.  
  413. initDOM() {
  414. // We want to use the comment meta bar for the folding control, so put
  415. // it back above the deleted comment placeholder.
  416. if (this.isDeleted) {
  417. this.$topBar.style.marginBottom = '4px'
  418. }
  419. this.$topBar.insertAdjacentText('afterbegin', ' ')
  420. this.$topBar.insertAdjacentElement('afterbegin', this.$toggleControl)
  421. this.$comhead.insertAdjacentElement('beforeend', this.$muteControl)
  422. }
  423.  
  424. mute() {
  425. if (this.user) {
  426. mutedUsers.add(this.user)
  427. setMutedUsers(mutedUsers)
  428. invalidateMuteCaches()
  429. hideMutedUsers()
  430. }
  431. }
  432.  
  433. /**
  434. * @param {boolean} updateChildren
  435. */
  436. updateDisplay(updateChildren = true) {
  437. // Show/hide this comment, preserving display of the meta bar
  438. toggleDisplay(this.$comment, this.isCollapsed)
  439. if (this.$vote) {
  440. toggleVisibility(this.$vote, this.isCollapsed)
  441. }
  442. this.$toggleControl.textContent = this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE
  443.  
  444. // Show/hide the number of child comments when collapsed
  445. if (this.isCollapsed && this.$collapsedChildCount == null) {
  446. let collapsedCommentCount = [
  447. this.isDeleted ? '(' : ' | (',
  448. this.childCommentCount,
  449. ` child${s(this.childCommentCount, 'ren')})`,
  450. ].join('')
  451. this.$collapsedChildCount = h('span', null, collapsedCommentCount)
  452. this.$comhead.appendChild(this.$collapsedChildCount)
  453. }
  454. toggleDisplay(this.$collapsedChildCount, !this.isCollapsed)
  455.  
  456. // Completely show/hide any child comments
  457. if (updateChildren) {
  458. this.childComments.forEach((child) => {
  459. if (!child.isMuted) {
  460. toggleDisplay(child.$wrapper, this.isCollapsed)
  461. }
  462. })
  463. }
  464. }
  465.  
  466. hide() {
  467. toggleDisplay(this.$wrapper, true)
  468. this.childComments.forEach((child) => toggleDisplay(child.$wrapper, true))
  469. }
  470.  
  471. /**
  472. * @param {number} commentId
  473. * @returns {boolean}
  474. */
  475. hasChildCommentsNewerThan(commentId) {
  476. return this.nonMutedChildComments.some((comment) => comment.isNewerThan(commentId))
  477. }
  478.  
  479. /**
  480. * @param {number} commentId
  481. * @returns {boolean}
  482. */
  483. isNewerThan(commentId) {
  484. return this.id > commentId
  485. }
  486.  
  487. /**
  488. * @param {boolean} isCollapsed
  489. */
  490. toggleCollapsed(isCollapsed = !this.isCollapsed) {
  491. this.isCollapsed = isCollapsed
  492. this.updateDisplay()
  493. }
  494.  
  495. /**
  496. * @param {boolean} highlight
  497. */
  498. toggleHighlighted(highlight) {
  499. this.$wrapper.style.backgroundColor = highlight ? HIGHLIGHT_COLOR : 'transparent'
  500. }
  501. }
  502.  
  503. /**
  504. * Adds checkboxes to toggle folding and highlighting when there are new
  505. * comments on a comment page.
  506. * @param {HTMLElement} $container
  507. */
  508. function addNewCommentControls($container) {
  509. $container.appendChild(
  510. h('div', null,
  511. h('p', null,
  512. `${newCommentCount} new comment${s(newCommentCount)} since ${lastVisit.time.toLocaleString()}`
  513. ),
  514. h('div', null,
  515. checkbox({
  516. checked: autoHighlightNew,
  517. onclick: (e) => {
  518. highlightNewComments(e.target.checked, lastVisit.maxCommentId)
  519. },
  520. }, 'highlight new comments'),
  521. ' ',
  522. checkbox({
  523. checked: autoHighlightNew,
  524. onclick: (e) => {
  525. collapseThreadsWithoutNewComments(e.target.checked, lastVisit.maxCommentId)
  526. },
  527. }, 'collapse threads without new comments'),
  528. ),
  529. )
  530. )
  531. }
  532.  
  533. /**
  534. * Adds a range control and button to show the last X new comments.
  535. */
  536. function addTimeTravelCommentControls($container) {
  537. let sortedCommentIds = comments.map((comment) => comment.id)
  538. .filter(id => id !== -1)
  539. .sort((a, b) => a - b)
  540.  
  541. let showNewCommentsAfter = Math.max(0, sortedCommentIds.length - 1)
  542. let howMany = sortedCommentIds.length - showNewCommentsAfter
  543.  
  544. function getButtonLabel() {
  545. let fromWhen = commentsById[sortedCommentIds[showNewCommentsAfter]].when
  546. // Older comments display `on ${date}` instead of a relative time
  547. if (fromWhen.startsWith(' on')) {
  548. fromWhen = fromWhen.replace(' on', 'since')
  549. }
  550. else {
  551. fromWhen = `from ${fromWhen}`
  552. }
  553. return `highlight ${howMany} comment${s(howMany)} ${fromWhen}`
  554. }
  555.  
  556. let $range = h('input', {
  557. max: sortedCommentIds.length - 1,
  558. min: 1,
  559. oninput(e) {
  560. showNewCommentsAfter = Number(e.target.value)
  561. howMany = sortedCommentIds.length - showNewCommentsAfter
  562. $button.value = getButtonLabel()
  563. },
  564. style: {margin: 0, verticalAlign: 'middle'},
  565. type: 'range',
  566. value: sortedCommentIds.length - 1,
  567. })
  568.  
  569. let $button = /** @type {HTMLInputElement} */ (h('input', {
  570. onclick() {
  571. let referenceCommentId = sortedCommentIds[showNewCommentsAfter - 1]
  572. log(`manually highlighting ${howMany} comments since ${referenceCommentId}`)
  573. highlightNewComments(true, referenceCommentId)
  574. collapseThreadsWithoutNewComments(true, referenceCommentId)
  575. $timeTravelControl.remove()
  576. },
  577. type: 'button',
  578. value: getButtonLabel(),
  579. }))
  580.  
  581. let $timeTravelControl = h('div', {
  582. style: {marginTop: '1em'},
  583. }, $range, ' ', $button)
  584.  
  585. $container.appendChild($timeTravelControl)
  586. }
  587.  
  588. /**
  589. * Adds the appropriate page controls depending on whether or not there are
  590. * new comments or any comments at all.
  591. */
  592. function addPageControls() {
  593. let $container = /** @type {HTMLElement} */ (document.querySelector('td.subtext'))
  594. if (!$container) {
  595. log('no container found for page controls')
  596. return
  597. }
  598.  
  599. if (hasNewComments) {
  600. addNewCommentControls($container)
  601. }
  602. else if (commentCount > 1) {
  603. addTimeTravelCommentControls($container)
  604. }
  605. }
  606.  
  607. /**
  608. * Collapses threads which don't have any comments newer than the given
  609. * comment id.
  610. * @param {boolean} collapse
  611. * @param {number} referenceCommentId
  612. */
  613. function collapseThreadsWithoutNewComments(collapse, referenceCommentId) {
  614. for (let i = 0; i < comments.length; i++) {
  615. let comment = comments[i]
  616. if (!comment.isNewerThan(referenceCommentId) &&
  617. !comment.hasChildCommentsNewerThan(referenceCommentId)) {
  618. comment.toggleCollapsed(collapse)
  619. // Skip over child comments
  620. i += comment.childComments.length
  621. }
  622. }
  623. }
  624.  
  625. function hideMutedUsers() {
  626. for (let i = 0; i < comments.length; i++) {
  627. let comment = comments[i]
  628. if (comment.isMuted) {
  629. comment.hide()
  630. // Skip over child comments
  631. i += comment.childComments.length
  632. }
  633. }
  634. }
  635.  
  636. function invalidateMuteCaches() {
  637. comments.forEach(comment => comment._nonMutedChildComments = null)
  638. }
  639.  
  640. function hideBuiltInCommentFoldingControls() {
  641. addStyle('a.togg { display: none; }')
  642. }
  643.  
  644. let toggleHideReplyLinks = (function() {
  645. let $style = addStyle()
  646. return () => {
  647. $style.textContent = config.hideReplyLinks ? `
  648. div.reply { margin-top: 8px; }
  649. div.reply p { display: none; }
  650. ` : ''
  651. }
  652. })()
  653.  
  654. /**
  655. * Highlights comments newer than the given comment id.
  656. * @param {boolean} highlight
  657. * @param {number} referenceCommentId
  658. */
  659. function highlightNewComments(highlight, referenceCommentId) {
  660. comments.forEach((comment) => {
  661. if (!comment.isMuted && comment.isNewerThan(referenceCommentId)) {
  662. comment.toggleHighlighted(highlight)
  663. }
  664. })
  665. }
  666.  
  667. function initComments() {
  668. let commentWrappers = /** @type {NodeListOf<HTMLTableRowElement>} */ (document.querySelectorAll('table.comment-tree tr.athing'))
  669. log('number of comment wrappers', commentWrappers.length)
  670. let index = 0
  671. let lastMaxCommentId = lastVisit != null ? lastVisit.maxCommentId : -1
  672. for (let $wrapper of commentWrappers) {
  673. let comment = new HNComment($wrapper, index++)
  674.  
  675. comments.push(comment)
  676.  
  677. if (comment.id != -1) {
  678. commentsById[comment.id] = comment
  679. }
  680.  
  681. if (comment.id > maxCommentId) {
  682. maxCommentId = comment.id
  683. }
  684.  
  685. if (!comment.isMuted && comment.isNewerThan(lastMaxCommentId)) {
  686. newCommentCount++
  687. }
  688. }
  689. hasNewComments = lastVisit != null && newCommentCount > 0
  690. }
  691.  
  692. // TODO Only store visit data when the item header is present (i.e. not a comment permalink)
  693. // TODO Only store visit data for commentable items (a reply box / reply links are visible)
  694. // TODO Clear any existing stored visit if the item is no longer commentable
  695. function storePageViewData() {
  696. storeVisit(itemId, new Visit({
  697. commentCount,
  698. maxCommentId,
  699. time: new Date(),
  700. }))
  701. }
  702.  
  703. lastVisit = getLastVisit(itemId)
  704.  
  705. let $commentsLink = document.querySelector('td.subtext > a[href^=item]')
  706. if ($commentsLink && /^\d+/.test($commentsLink.textContent)) {
  707. commentCount = Number($commentsLink.textContent.split(/\s/).shift())
  708. }
  709.  
  710. hideBuiltInCommentFoldingControls()
  711. toggleHideReplyLinks()
  712. initComments()
  713. comments.filter(comment => comment.isCollapsed).forEach(comment => comment.updateDisplay(false))
  714. if (hasNewComments && autoHighlightNew) {
  715. highlightNewComments(true, lastVisit.maxCommentId)
  716. collapseThreadsWithoutNewComments(true, lastVisit.maxCommentId)
  717. }
  718. hideMutedUsers()
  719. addPageControls()
  720. storePageViewData()
  721.  
  722. log('page view data', {
  723. autoHighlightNew,
  724. commentCount,
  725. hasNewComments,
  726. itemId,
  727. lastVisit,
  728. maxCommentId,
  729. newCommentCount,
  730. })
  731.  
  732. chrome.storage.onChanged.addListener((changes) => {
  733. if ('hideReplyLinks' in changes) {
  734. config.hideReplyLinks = changes['hideReplyLinks'].newValue
  735. toggleHideReplyLinks()
  736. }
  737. })
  738. }
  739. //#endregion
  740.  
  741. //#region Feature: new comment indicators on link pages
  742. /**
  743. * Each item on an item list page has the following structure:
  744. *
  745. * ```html
  746. * <tr class="athing">…</td> (rank, upvote control, title/link and domain)
  747. * <tr>
  748. * <td>…</td> (spacer)
  749. * <td class="subtext">…</td> (item meta info)
  750. * </tr>
  751. * <tr class="spacer">…</tr>
  752. * ```
  753. *
  754. * Using the comment count stored when you visit a comment page, we'll display
  755. * the number of new comments in the subtext section and provide a link which
  756. * will automatically highlight new comments and collapse comment trees without
  757. * new comments.
  758. *
  759. * For regular stories, the subtext element contains points, user, age (in
  760. * a link to the comments page), flag/hide controls and finally the number of
  761. * comments (in another link to the comments page). We'll look for the latter
  762. * to detemine the current number of comments and the item id.
  763. *
  764. * For job postings, the subtext element only contains age (in
  765. * a link to the comments page) and a hide control, so we'll try to ignore
  766. * those.
  767. */
  768. function itemListPage() {
  769. log('item list page')
  770.  
  771. let commentLinks = /** @type {NodeListOf<HTMLAnchorElement>} */ (document.querySelectorAll('td.subtext > a[href^="item?id="]:last-child'))
  772. log('number of comments/discuss links', commentLinks.length)
  773.  
  774. let noCommentsCount = 0
  775. let noLastVisitCount = 0
  776.  
  777. for (let $commentLink of commentLinks) {
  778. let id = $commentLink.href.split('=').pop()
  779.  
  780. let commentCountMatch = /^(\d+)/.exec($commentLink.textContent)
  781. if (commentCountMatch == null) {
  782. noCommentsCount++
  783. continue
  784. }
  785.  
  786. let lastVisit = getLastVisit(id)
  787. if (lastVisit == null) {
  788. noLastVisitCount++
  789. continue
  790. }
  791.  
  792. let commentCount = Number(commentCountMatch[1])
  793. if (commentCount <= lastVisit.commentCount) {
  794. log(`${id} doesn't have any new comments`, lastVisit)
  795. continue
  796. }
  797.  
  798. $commentLink.insertAdjacentElement('afterend',
  799. h('span', null,
  800. ' (',
  801. h('a', {
  802. href: `/item?shownew&id=${id}`,
  803. style: {fontWeight: 'bold'},
  804. },
  805. commentCount - lastVisit.commentCount,
  806. ' new'
  807. ),
  808. ')',
  809. )
  810. )
  811. }
  812.  
  813. if (noCommentsCount > 0) {
  814. log(`${noCommentsCount} item${s(noCommentsCount, " doesn't,s don't")} have any comments`)
  815. }
  816. if (noLastVisitCount > 0) {
  817. log(`${noLastVisitCount} item${s(noLastVisitCount, " doesn't,s don't")} have a last visit stored`)
  818. }
  819. }
  820. //#endregion
  821.  
  822. //#region Feature: mute/unmute users on profile pages
  823. function userProfilePage() {
  824. log('user profile page')
  825.  
  826. let $userLink = /** @type {HTMLAnchorElement} */ (document.querySelector('a.hnuser'))
  827. if ($userLink == null) {
  828. log('not a valid user')
  829. return
  830. }
  831.  
  832. let userId = $userLink.innerText
  833. let $currentUserLink = /** @type {HTMLAnchorElement} */ (document.querySelector('a#me'))
  834. let currentUser = $currentUserLink?.innerText ?? ''
  835. let mutedUsers = getMutedUsers()
  836. let $tbody = $userLink.closest('table').querySelector('tbody')
  837.  
  838. if (userId == currentUser) {
  839. let first = 0
  840. mutedUsers.forEach((mutedUserId) => {
  841. $tbody.appendChild(
  842. h('tr', null,
  843. h('td', {valign: 'top'}, first++ == 0 ? 'muted:' : ''),
  844. h('td', null,
  845. h('a', {href: `/user?id=${mutedUserId}`}, mutedUserId),
  846. h('a', {
  847. href: '#',
  848. onClick: function(e) {
  849. e.preventDefault()
  850. if (mutedUsers.has(mutedUserId)) {
  851. mutedUsers.delete(mutedUserId)
  852. this.firstElementChild.innerText = 'mute'
  853. }
  854. else {
  855. mutedUsers.add(mutedUserId)
  856. this.firstElementChild.innerText = 'unmute'
  857. }
  858. setMutedUsers(mutedUsers)
  859. }
  860. },
  861. ' (', h('u', null, 'unmute'), ')'
  862. )
  863. )
  864. )
  865. )
  866. })
  867. }
  868. else {
  869. $tbody.appendChild(
  870. h('tr', null,
  871. h('td'),
  872. h('td', null,
  873. h('a', {
  874. href: '#',
  875. onClick: function(e) {
  876. e.preventDefault()
  877. if (mutedUsers.has(userId)) {
  878. mutedUsers.delete(userId)
  879. this.firstElementChild.innerText = 'mute'
  880. }
  881. else {
  882. mutedUsers.add(userId)
  883. this.firstElementChild.innerText = 'unmute'
  884. }
  885. setMutedUsers(mutedUsers)
  886. }
  887. },
  888. h('u', null, mutedUsers.has(userId) ? 'unmute' : 'mute')
  889. )
  890. )
  891. )
  892. )
  893. }
  894. }
  895. //#endregion
  896.  
  897. //#region Main
  898. function main() {
  899. log('config', config)
  900.  
  901. if (config.addUpvotedToHeader) {
  902. addUpvotedLinkToHeader()
  903. }
  904.  
  905. let path = location.pathname.slice(1)
  906.  
  907. if (/^($|active|ask|best|front|news|newest|noobstories|show|submitted|upvoted)/.test(path)) {
  908. itemListPage()
  909. }
  910. else if (/^item/.test(path)) {
  911. commentPage()
  912. }
  913. else if (/^user/.test(path)) {
  914. userProfilePage()
  915. }
  916. }
  917.  
  918. if (
  919. typeof GM == 'undefined' &&
  920. typeof chrome != 'undefined' &&
  921. typeof chrome.storage != 'undefined'
  922. ) {
  923. chrome.storage.local.get((storedConfig) => {
  924. Object.assign(config, storedConfig)
  925. main()
  926. })
  927. }
  928. else {
  929. main()
  930. }
  931. //#endregion