Comments Owl for Hacker News

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

当前为 2023-07-16 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Comments Owl for Hacker News
  3. // @description Highlight new comments, mute users, and other tweaks for Hacker News
  4. // @namespace https://github.com/insin/comments-owl-for-hacker-news/
  5. // @match https://news.ycombinator.com/*
  6. // @version 44
  7. // ==/UserScript==
  8. let debug = false
  9. let isSafari = navigator.userAgent.includes('Safari/') && !/Chrom(e|ium)\//.test(navigator.userAgent)
  10.  
  11. const HIGHLIGHT_COLOR = '#ffffde'
  12. const TOGGLE_HIDE = '[–]'
  13. const TOGGLE_SHOW = '[+]'
  14. const LOGGED_OUT_USER_PAGE = `<head>
  15. <meta name="referrer" content="origin">
  16. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  17. <link rel="stylesheet" type="text/css" href="news.css">
  18. <link rel="shortcut icon" href="favicon.ico">
  19. <title>Muted | Comments Owl for Hacker News</title>
  20. </head>
  21. <body>
  22. <center>
  23. <table id="hnmain" width="85%" cellspacing="0" cellpadding="0" border="0" bgcolor="#f6f6ef">
  24. <tbody>
  25. <tr>
  26. <td bgcolor="#ff6600">
  27. <table style="padding: 2px" width="100%" cellspacing="0" cellpadding="0" border="0">
  28. <tbody>
  29. <tr>
  30. <td style="width: 18px; padding-right: 4px">
  31. <a href="https://news.ycombinator.com">
  32. <img src="y18.svg" style="border: 1px white solid; display: block" width="18" height="18">
  33. </a>
  34. </td>
  35. <td style="line-height: 12pt; height: 10px">
  36. <span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
  37. <a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a>
  38. </span>
  39. </td>
  40. <td style="text-align: right; padding-right: 4px">
  41. <span class="pagetop">
  42. <a href="login?goto=news">login</a>
  43. </span>
  44. </td>
  45. </tr>
  46. </tbody>
  47. </table>
  48. </td>
  49. </tr>
  50. <tr id="pagespace" title="Muted" style="height: 10px"></tr>
  51. <tr>
  52. <td>
  53. <table border="0">
  54. <tbody>
  55. <tr class="athing">
  56. <td valign="top">user:</td>
  57. <td>
  58. <a class="hnuser">anonymous comments owl user</a>
  59. </td>
  60. </tr>
  61. </tbody>
  62. </table>
  63. <br><br>
  64. </td>
  65. </tr>
  66. </tbody>
  67. </table>
  68. </center>
  69. </body>`
  70.  
  71. //#region Config
  72. /** @type {import("./types").Config} */
  73. let config = {
  74. addUpvotedToHeader: true,
  75. autoCollapseNotNew: true,
  76. autoHighlightNew: true,
  77. hideCommentsNav: false,
  78. hideJobsNav: false,
  79. hidePastNav: false,
  80. hideReplyLinks: false,
  81. hideSubmitNav: false,
  82. listPageFlagging: 'enabled',
  83. }
  84. //#endregion
  85.  
  86. //#region Storage
  87. class Visit {
  88. constructor({commentCount, maxCommentId, time}) {
  89. /** @type {number} */
  90. this.commentCount = commentCount
  91. /** @type {number} */
  92. this.maxCommentId = maxCommentId
  93. /** @type {Date} */
  94. this.time = time
  95. }
  96.  
  97. toJSON() {
  98. return {
  99. c: this.commentCount,
  100. m: this.maxCommentId,
  101. t: this.time.getTime(),
  102. }
  103. }
  104. }
  105.  
  106. Visit.fromJSON = function(obj) {
  107. return new Visit({
  108. commentCount: obj.c,
  109. maxCommentId: obj.m,
  110. time: new Date(obj.t),
  111. })
  112. }
  113.  
  114. function getLastVisit(itemId) {
  115. let json = localStorage.getItem(itemId)
  116. if (json == null) return null
  117. return Visit.fromJSON(JSON.parse(json))
  118. }
  119.  
  120. function storeVisit(itemId, visit) {
  121. log('storing visit', visit)
  122. localStorage.setItem(itemId, JSON.stringify(visit))
  123. }
  124.  
  125. function getMutedUsers() {
  126. return new Set(JSON.parse(localStorage.mutedUsers || '[]'))
  127. }
  128.  
  129. function getUserNotes() {
  130. return JSON.parse(localStorage.userNotes || '{}')
  131. }
  132.  
  133. function setMutedUsers(mutedUsers) {
  134. localStorage.mutedUsers = JSON.stringify(Array.from(mutedUsers))
  135. }
  136.  
  137. function setUserNotes(userNotes) {
  138. localStorage.userNotes = JSON.stringify(userNotes)
  139. }
  140. //#endregion
  141.  
  142. //#region Utility functions
  143. /**
  144. * @param {string} role
  145. * @param {...string} css
  146. */
  147. function addStyle(role, ...css) {
  148. let $style = document.createElement('style')
  149. $style.dataset.insertedBy = 'comments-owl'
  150. $style.dataset.role = role
  151. if (css.length > 0) {
  152. $style.textContent = css.filter(Boolean).map(dedent).join('\n')
  153. }
  154. document.querySelector('head').appendChild($style)
  155. return $style
  156. }
  157.  
  158. const autosizeTextArea = (() => {
  159. /** @type {Number} */
  160. let textAreaPadding
  161.  
  162. return function autosizeTextarea($textArea) {
  163. if (textAreaPadding == null) {
  164. textAreaPadding = Number(getComputedStyle($textArea).paddingTop.replace('px', '')) * 2
  165. }
  166. $textArea.style.height = '0px'
  167. $textArea.style.height = $textArea.scrollHeight + textAreaPadding + 'px'
  168. }
  169. })()
  170.  
  171. function checkbox(attributes, label) {
  172. return h('label', null,
  173. h('input', {
  174. style: {verticalAlign: 'middle'},
  175. type: 'checkbox',
  176. ...attributes,
  177. }),
  178. ' ',
  179. label,
  180. )
  181. }
  182.  
  183. /**
  184. * @param {string} str
  185. * @return {string}
  186. */
  187. function dedent(str) {
  188. str = str.replace(/^[ \t]*\r?\n/, '')
  189. let indent = /^[ \t]+/m.exec(str)
  190. if (indent) str = str.replace(new RegExp('^' + indent[0], 'gm'), '')
  191. return str.replace(/(\r?\n)[ \t]+$/, '$1')
  192. }
  193.  
  194. /**
  195. * Create an element.
  196. * @param {string} tagName
  197. * @param {{[key: string]: any}} [attributes]
  198. * @param {...any} children
  199. * @returns {HTMLElement}
  200. */
  201. function h(tagName, attributes, ...children) {
  202. let $el = document.createElement(tagName)
  203.  
  204. if (attributes) {
  205. for (let [prop, value] of Object.entries(attributes)) {
  206. if (prop.indexOf('on') === 0) {
  207. $el.addEventListener(prop.slice(2).toLowerCase(), value)
  208. }
  209. else if (prop.toLowerCase() == 'style') {
  210. for (let [styleProp, styleValue] of Object.entries(value)) {
  211. $el.style[styleProp] = styleValue
  212. }
  213. }
  214. else {
  215. $el[prop] = value
  216. }
  217. }
  218. }
  219.  
  220. for (let child of children) {
  221. if (child == null || child === false) {
  222. continue
  223. }
  224. if (child instanceof Node) {
  225. $el.appendChild(child)
  226. }
  227. else {
  228. $el.insertAdjacentText('beforeend', String(child))
  229. }
  230. }
  231.  
  232. return $el
  233. }
  234.  
  235. function log(...args) {
  236. if (debug) {
  237. console.log('🦉', ...args)
  238. }
  239. }
  240.  
  241. function warn(...args) {
  242. if (debug) {
  243. console.log('❗', ...args)
  244. }
  245. }
  246.  
  247. /**
  248. * @param {number} count
  249. * @param {string} suffixes
  250. * @returns {string}
  251. */
  252. function s(count, suffixes = ',s') {
  253. if (!suffixes.includes(',')) {
  254. suffixes = `,${suffixes}`
  255. }
  256. return suffixes.split(',')[count === 1 ? 0 : 1]
  257. }
  258.  
  259. /**
  260. * @param {HTMLElement} $el
  261. * @param {boolean} hidden
  262. */
  263. function toggleDisplay($el, hidden) {
  264. $el.classList.toggle('noshow', hidden)
  265. // We need to enforce display setting as the page's own script expands all
  266. // comments on page load.
  267. $el.style.display = hidden ? 'none' : ''
  268. }
  269.  
  270. /**
  271. * @param {HTMLElement} $el
  272. * @param {boolean} hidden
  273. */
  274. function toggleVisibility($el, hidden) {
  275. $el.classList.toggle('nosee', hidden)
  276. // We need to enforce visibility setting as the page's own script expands
  277. // all comments on page load.
  278. $el.style.visibility = hidden ? 'hidden' : 'visible'
  279. }
  280. //#endregion
  281.  
  282. //#region Navigation
  283. function tweakNav() {
  284. let $pageTop = document.querySelector('span.pagetop')
  285. if (!$pageTop) {
  286. warn('pagetop not found')
  287. return
  288. }
  289.  
  290. //#region CSS
  291. addStyle('nav-static', `
  292. .desktopnav {
  293. display: inline;
  294. }
  295. .mobilenav {
  296. display: none;
  297. }
  298. @media only screen and (min-width : 300px) and (max-width : 750px) {
  299. .desktopnav {
  300. display: none;
  301. }
  302. .mobilenav {
  303. display: revert;
  304. }
  305. }
  306. `)
  307.  
  308. let $style = addStyle('nav-dynamic')
  309.  
  310. function configureCss() {
  311. let hideNavSelectors = [
  312. config.hidePastNav && 'span.past-sep, span.past-sep + a',
  313. config.hideCommentsNav && 'span.comments-sep, span.comments-sep + a',
  314. config.hideJobsNav && 'span.jobs-sep, span.jobs-sep + a',
  315. config.hideSubmitNav && 'span.submit-sep, span.submit-sep + a',
  316. !config.addUpvotedToHeader && 'span.upvoted-sep, span.upvoted-sep + a',
  317. ].filter(Boolean)
  318. $style.textContent = hideNavSelectors.length == 0 ? '' : dedent(`
  319. ${hideNavSelectors.join(',\n')} {
  320. display: none;
  321. }
  322. `)
  323. }
  324. //#endregion
  325.  
  326. //#region Main
  327. // Add a 'muted' link next to 'login' for logged-out users
  328. let $loginLink = document.querySelector('span.pagetop a[href^="login"]')
  329. if ($loginLink) {
  330. $loginLink.parentElement.append(
  331. h('a', {href: `muted`}, 'muted'),
  332. ' | ',
  333. $loginLink,
  334. )
  335. }
  336.  
  337. // Add /upvoted if we're not on it and the user is logged in
  338. if (!location.pathname.startsWith('/upvoted')) {
  339. let $userLink = document.querySelector('span.pagetop a[href^="user?id"]')
  340. if ($userLink) {
  341. let $submit = $pageTop.querySelector('a[href="submit"]')
  342. $submit.insertAdjacentElement('afterend', h('a', {href: `upvoted?id=${$userLink.textContent}`}, 'upvoted'))
  343. $submit.insertAdjacentElement('afterend', h('span', {className: 'upvoted-sep'}, ' | '))
  344. }
  345. }
  346.  
  347. // Wrap separators in elements so they can be used to hide items
  348. Array.from($pageTop.childNodes)
  349. .filter(n => n.nodeType == Node.TEXT_NODE && n.nodeValue == ' | ')
  350. .forEach(n => n.replaceWith(h('span', {className: `${n.nextSibling?.textContent}-sep`}, ' | ')))
  351.  
  352. // Create a new row for mobile nav
  353. let $mobileNav = /** @type {HTMLTableCellElement} */ ($pageTop.parentElement.cloneNode(true))
  354. $mobileNav.querySelector('b')?.remove()
  355. $mobileNav.colSpan = 3
  356. $pageTop.closest('tbody').append(h('tr', {className: 'mobilenav'}, $mobileNav))
  357.  
  358. // Move everything after b.hnname into a desktop nav wrapper
  359. $pageTop.appendChild(h('span', {className: 'desktopnav'}, ...Array.from($pageTop.childNodes).slice(1)))
  360.  
  361. configureCss()
  362.  
  363. chrome.storage.onChanged.addListener((changes) => {
  364. for (let [configProp, change] of Object.entries(changes)) {
  365. if (['hidePastNav', 'hideCommentsNav', 'hideJobsNav', 'hideSubmitNav', 'addUpvotedToHeader'].includes(configProp)) {
  366. config[configProp] = change.newValue
  367. configureCss()
  368. }
  369. }
  370. })
  371. //#endregion
  372. }
  373. //#endregion
  374.  
  375. //#region Comment page
  376. /**
  377. * Each comment on a comment page has the following structure:
  378. *
  379. * ```html
  380. * <tr class="athing"> (wrapper)
  381. * <td>
  382. * <table>
  383. * <tr>
  384. * <td class="ind">
  385. * <img src="s.gif" height="1" width="123"> (indentation)
  386. * </td>
  387. * <td class="votelinks">…</td> (vote up/down controls)
  388. * <td class="default">
  389. * <div style="margin-top:2px; margin-bottom:-10px;">
  390. * <div class="comhead"> (meta bar: user, age and folding control)
  391. * …
  392. * <div class="comment">
  393. * <span class="comtext"> (text and reply link)
  394. * ```
  395. *
  396. * We want to be able to collapse comment trees which don't contain new comments
  397. * and highlight new comments, so for each wrapper we'll create a `HNComment`
  398. * object to manage this.
  399. *
  400. * Comments are rendered as a flat list of table rows, so we'll use the width of
  401. * the indentation spacer to determine which comments are descendants of a given
  402. * comment.
  403. *
  404. * Since we have to reimplement our own comment folding, we'll hide the built-in
  405. * folding controls and create new ones in a better position (on the left), with
  406. * a larger hitbox (larger font and an en dash [–] instead of a hyphen [-]).
  407. *
  408. * On each comment page view, we store the current comment count, the max
  409. * comment id on the page and the current time as the last visit time.
  410. */
  411. function commentPage() {
  412. log('comment page')
  413.  
  414. //#region CSS
  415. addStyle('comments-static', `
  416. /* Hide default toggle and nav links */
  417. a.togg {
  418. display: none;
  419. }
  420. .navs {
  421. display: none;
  422. }
  423. .toggle {
  424. cursor: pointer;
  425. margin-right: 3px;
  426. }
  427. /* Display the mute control on hover, unless the comment is collapsed */
  428. .mute {
  429. display: none;
  430. }
  431. /* Prevent :hover causing double-tap on comment functionality in iOS Safari */
  432. @media(hover: hover) and (pointer: fine) {
  433. tr.comtr:hover td.votelinks:not(.nosee) + td .mute {
  434. display: inline;
  435. }
  436. }
  437. /* Don't show notes on collapsed comments */
  438. td.votelinks.nosee + td .note {
  439. display: none;
  440. }
  441. #timeTravel {
  442. margin-top: 1em;
  443. vertical-align: middle;
  444. }
  445. #timeTravelRange {
  446. width: 100%;
  447. }
  448. #timeTravelButton {
  449. margin-right: 1em;
  450. }
  451.  
  452. @media only screen and (min-width: 300px) and (max-width: 750px) {
  453. td.votelinks:not(.nosee) + td .mute {
  454. display: inline;
  455. }
  456. /* Allow comments to go full-width */
  457. .comment {
  458. max-width: unset;
  459. }
  460. /* Increase distance between upvote and downvote */
  461. a[id^="down_"] {
  462. margin-top: 16px;
  463. }
  464. /* Increase hit-target */
  465. .toggle {
  466. font-size: 14px;
  467. }
  468. #highlightControls label {
  469. display: block;
  470. }
  471. #highlightControls label + label {
  472. margin-top: .5rem;
  473. }
  474. #timeTravelRange {
  475. width: calc(100% - 32px);
  476. }
  477. }
  478. `)
  479.  
  480. let $style = addStyle('comments-dynamic')
  481.  
  482. function configureCss() {
  483. $style.textContent = [
  484. config.hideReplyLinks && `
  485. div.reply {
  486. margin-top: 8px;
  487. }
  488. div.reply p {
  489. display: none;
  490. }
  491. `
  492. ].filter(Boolean).map(dedent).join('\n')
  493. }
  494. //#endregion
  495.  
  496. //#region Variables
  497. /** @type {boolean} */
  498. let autoCollapseNotNew = config.autoCollapseNotNew || location.search.includes('?shownew')
  499.  
  500. /** @type {boolean} */
  501. let autoHighlightNew = config.autoHighlightNew || location.search.includes('?shownew')
  502.  
  503. /** @type {HNComment[]} */
  504. let comments = []
  505.  
  506. /** @type {Record<string, HNComment>} */
  507. let commentsById = {}
  508.  
  509. /** @type {boolean} */
  510. let hasNewComments = false
  511.  
  512. /** @type {string} */
  513. let itemId = /id=(\d+)/.exec(location.search)[1]
  514.  
  515. /** @type {Visit} */
  516. let lastVisit
  517.  
  518. /** @type {number} */
  519. let maxCommentId
  520.  
  521. /** @type {Set<string>} */
  522. let mutedUsers = getMutedUsers()
  523.  
  524. /** @type {Record<string, string>} */
  525. let userNotes = getUserNotes()
  526.  
  527. // Comment counts
  528. let commentCount = 0
  529. let mutedCommentCount = 0
  530. let newCommentCount = 0
  531. let replyToMutedCommentCount = 0
  532. //#endregion
  533.  
  534. class HNComment {
  535. /**
  536. * returns {boolean}
  537. */
  538. get isMuted() {
  539. return mutedUsers.has(this.user)
  540. }
  541.  
  542. /**
  543. * @returns {HNComment[]}
  544. */
  545. get childComments() {
  546. if (this._childComments == null) {
  547. this._childComments = []
  548. for (let i = this.index + 1; i < comments.length; i++) {
  549. if (comments[i].indent <= this.indent) {
  550. break
  551. }
  552. this._childComments.push(comments[i])
  553. }
  554. }
  555. return this._childComments
  556. }
  557.  
  558. get collapsedChildrenText() {
  559. return this.childCommentCount == 0 ? '' : [
  560. this.isDeleted ? '(' : ' | (',
  561. this.childCommentCount,
  562. ` child${s(this.childCommentCount, 'ren')})`,
  563. ].join('')
  564. }
  565.  
  566. /**
  567. * @returns {HNComment[]}
  568. */
  569. get nonMutedChildComments() {
  570. if (this._nonMutedChildComments == null) {
  571. let muteIndent = null
  572. this._nonMutedChildComments = this.childComments.filter(comment => {
  573. if (muteIndent != null) {
  574. if (comment.indent > muteIndent) {
  575. return false
  576. }
  577. muteIndent = null
  578. }
  579.  
  580. if (comment.isMuted) {
  581. muteIndent = comment.indent
  582. return false
  583. }
  584.  
  585. return true
  586. })
  587. }
  588. return this._nonMutedChildComments
  589. }
  590.  
  591. /**
  592. * returns {number}
  593. */
  594. get childCommentCount() {
  595. return this.nonMutedChildComments.length
  596. }
  597.  
  598. /**
  599. * @param {HTMLElement} $wrapper
  600. * @param {number} index
  601. */
  602. constructor($wrapper, index) {
  603. /** @type {number} */
  604. this.indent = Number( /** @type {HTMLImageElement} */ ($wrapper.querySelector('img[src="s.gif"]')).width)
  605.  
  606. /** @type {number} */
  607. this.index = index
  608.  
  609. let $user = /** @type {HTMLElement} */ ($wrapper.querySelector('a.hnuser'))
  610. /** @type {string} */
  611. this.user = $user?.innerText
  612.  
  613. /** @type {HTMLElement} */
  614. this.$comment = $wrapper.querySelector('div.comment')
  615.  
  616. /** @type {HTMLElement} */
  617. this.$topBar = $wrapper.querySelector('td.default > div')
  618.  
  619. /** @type {HTMLElement} */
  620. this.$voteLinks = $wrapper.querySelector('td.votelinks')
  621.  
  622. /** @type {HTMLElement} */
  623. this.$wrapper = $wrapper
  624.  
  625. /** @private @type {HNComment[]} */
  626. this._childComments = null
  627.  
  628. /** @private @type {HNComment[]} */
  629. this._nonMutedChildComments = null
  630.  
  631. /**
  632. * The comment's id.
  633. * Will be `-1` for deleted comments.
  634. * @type {number}
  635. */
  636. this.id = -1
  637.  
  638. /**
  639. * Some flagged comments are collapsed by default.
  640. * @type {boolean}
  641. */
  642. this.isCollapsed = $wrapper.classList.contains('coll')
  643.  
  644. /**
  645. * Comments whose text has been removed but are still displayed may have
  646. * their text replaced with [flagged], [dead] or similar - we'll take any
  647. * word in square brackets as indication of this.
  648. * @type {boolean}
  649. */
  650. this.isDeleted = /^\s*\[\w+]\s*$/.test(this.$comment.firstChild.nodeValue)
  651.  
  652. /**
  653. * The displayed age of the comment; `${n} minutes/hours/days ago`, or
  654. * `on ${date}` for older comments.
  655. * Will be blank for deleted comments.
  656. * @type {string}
  657. */
  658. this.when = ''
  659.  
  660. /** @type {HTMLElement} */
  661. this.$childCount = null
  662.  
  663. /** @type {HTMLElement} */
  664. this.$comhead = this.$topBar.querySelector('span.comhead')
  665.  
  666. /** @type {HTMLElement} */
  667. this.$toggleControl = h('span', {
  668. className: 'toggle',
  669. onclick: () => this.toggleCollapsed(),
  670. }, this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE)
  671.  
  672. if (!this.isDeleted) {
  673. let $permalink = /** @type {HTMLAnchorElement} */ (this.$topBar.querySelector('a[href^=item]'))
  674. this.id = Number($permalink.href.split('=').pop())
  675. this.when = $permalink?.textContent.replace('minute', 'min')
  676. }
  677. }
  678.  
  679. addControls() {
  680. // We want to use the comment meta bar for the folding control, so put
  681. // it back above the deleted comment placeholder.
  682. if (this.isDeleted) {
  683. this.$topBar.style.marginBottom = '4px'
  684. }
  685. this.$topBar.insertAdjacentText('afterbegin', ' ')
  686. this.$topBar.insertAdjacentElement('afterbegin', this.$toggleControl)
  687. this.$comhead.append(...[
  688. // User note
  689. userNotes[this.user] && h('span', {className: 'note'},
  690. ` | nb: ${userNotes[this.user].split(/\r?\n/)[0]}`,
  691. ),
  692. // Mute control
  693. this.user && h('span', {className: 'mute'}, ' | ', h('a', {
  694. href: `mute?id=${this.user}`,
  695. onclick: (e) => {
  696. e.preventDefault()
  697. this.mute()
  698. }
  699. }, 'mute'))
  700. ].filter(Boolean))
  701. }
  702.  
  703. mute() {
  704. mutedUsers.add(this.user)
  705. setMutedUsers(mutedUsers)
  706.  
  707. // Invalidate non-muted child caches and update child counts on any
  708. // comments which have been collapsed.
  709. for (let i = 0; i < comments.length; i++) {
  710. let comment = comments[i]
  711.  
  712. if (comment.isMuted) {
  713. i += comment.childComments.length
  714. continue
  715. }
  716.  
  717. comment._nonMutedChildComments = null
  718. if (comment.$childCount) {
  719. comment.$childCount.textContent = comment.collapsedChildrenText
  720. }
  721. }
  722.  
  723. hideMutedUsers()
  724. }
  725.  
  726. /**
  727. * @param {boolean} updateChildren
  728. */
  729. updateDisplay(updateChildren = true) {
  730. // Show/hide this comment, preserving display of the meta bar
  731. toggleDisplay(this.$comment, this.isCollapsed)
  732. if (this.$voteLinks) {
  733. toggleVisibility(this.$voteLinks, this.isCollapsed)
  734. }
  735. this.$toggleControl.textContent = this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE
  736.  
  737. // Show/hide the number of child comments when collapsed
  738. if (this.childCommentCount > 0) {
  739. if (this.isCollapsed && this.$childCount == null) {
  740. this.$childCount = h('span', null, this.collapsedChildrenText)
  741. this.$comhead.appendChild(this.$childCount)
  742. }
  743. toggleDisplay(this.$childCount, !this.isCollapsed)
  744. }
  745.  
  746. if (updateChildren) {
  747. for (let i = 0; i < this.nonMutedChildComments.length; i++) {
  748. let child = this.nonMutedChildComments[i]
  749. toggleDisplay(child.$wrapper, this.isCollapsed)
  750. if (child.isCollapsed) {
  751. i += child.childComments.length
  752. }
  753. }
  754. }
  755. }
  756.  
  757. /**
  758. * Completely hides this comment and its replies.
  759. */
  760. hide() {
  761. toggleDisplay(this.$wrapper, true)
  762. this.childComments.forEach((child) => toggleDisplay(child.$wrapper, true))
  763. }
  764.  
  765. /**
  766. * @param {number} commentId
  767. * @returns {boolean}
  768. */
  769. hasChildCommentsNewerThan(commentId) {
  770. return this.nonMutedChildComments.some((comment) => comment.isNewerThan(commentId))
  771. }
  772.  
  773. /**
  774. * @param {number} commentId
  775. * @returns {boolean}
  776. */
  777. isNewerThan(commentId) {
  778. return this.id > commentId
  779. }
  780.  
  781. /**
  782. * @param {boolean} isCollapsed
  783. */
  784. toggleCollapsed(isCollapsed = !this.isCollapsed) {
  785. this.isCollapsed = isCollapsed
  786. this.updateDisplay()
  787. }
  788.  
  789. /**
  790. * @param {boolean} highlight
  791. */
  792. toggleHighlighted(highlight) {
  793. this.$wrapper.style.backgroundColor = highlight ? HIGHLIGHT_COLOR : 'transparent'
  794. }
  795. }
  796.  
  797. //#region Functions
  798. function addHighlightCommentsControl($container) {
  799. let $highlightComments = h('span', null, ' | ', h('a', {
  800. href: '#',
  801. onClick(e) {
  802. e.preventDefault()
  803. addTimeTravelCommentControls($container)
  804. $highlightComments.remove()
  805. },
  806. }, 'highlight comments'))
  807.  
  808. $container.querySelector('.subline')?.append($highlightComments)
  809. }
  810.  
  811. /**
  812. * Adds checkboxes to toggle folding and highlighting when there are new
  813. * comments on a comment page.
  814. * @param {HTMLElement} $container
  815. */
  816. function addNewCommentControls($container) {
  817. $container.appendChild(
  818. h('div', null,
  819. h('p', null,
  820. `${newCommentCount} new comment${s(newCommentCount)} since ${lastVisit.time.toLocaleString()}`
  821. ),
  822. h('div', {id: 'highlightControls'},
  823. checkbox({
  824. checked: autoHighlightNew,
  825. onclick: (e) => {
  826. highlightNewComments(e.target.checked, lastVisit.maxCommentId)
  827. },
  828. }, 'highlight new comments'),
  829. ' ',
  830. checkbox({
  831. checked: autoCollapseNotNew,
  832. onclick: (e) => {
  833. collapseThreadsWithoutNewComments(e.target.checked, lastVisit.maxCommentId)
  834. },
  835. }, 'collapse threads without new comments'),
  836. ),
  837. )
  838. )
  839. }
  840.  
  841. /**
  842. * Adds the appropriate page controls depending on whether or not there are
  843. * new comments or any comments at all.
  844. */
  845. function addPageControls() {
  846. let $container = /** @type {HTMLElement} */ (document.querySelector('td.subtext'))
  847. if (!$container) {
  848. warn('no container found for page controls')
  849. return
  850. }
  851.  
  852. if (hasNewComments) {
  853. addNewCommentControls($container)
  854. }
  855. else if (commentCount > 1) {
  856. addHighlightCommentsControl($container)
  857. }
  858. }
  859.  
  860. /**
  861. * Adds a range control and button to show the last X new comments.
  862. */
  863. function addTimeTravelCommentControls($container) {
  864. let sortedCommentIds = []
  865. for (let i = 0; i < comments.length; i++) {
  866. let comment = comments[i]
  867. if (comment.isMuted) {
  868. // Skip muted comments and their replies as they're always hidden
  869. i += comment.childComments.length
  870. continue
  871. }
  872. sortedCommentIds.push(comment.id)
  873. }
  874. sortedCommentIds.sort()
  875.  
  876. let showNewCommentsAfter = Math.max(0, sortedCommentIds.length - 1)
  877. let howMany = sortedCommentIds.length - showNewCommentsAfter
  878.  
  879. function getRangeDescription() {
  880. let fromWhen = commentsById[sortedCommentIds[showNewCommentsAfter]].when
  881. // Older comments display `on ${date}` instead of a relative time
  882. if (fromWhen.startsWith(' on')) {
  883. fromWhen = fromWhen.replace(' on', 'since')
  884. }
  885. else {
  886. fromWhen = `from ${fromWhen}`
  887. }
  888. return `${howMany} ${fromWhen}`
  889. }
  890.  
  891. let $description = h('span', null, getRangeDescription())
  892.  
  893. let $range = h('input', {
  894. id: 'timeTravelRange',
  895. max: sortedCommentIds.length - 1,
  896. min: 1,
  897. oninput(e) {
  898. showNewCommentsAfter = Number(e.target.value)
  899. howMany = sortedCommentIds.length - showNewCommentsAfter
  900. $description.textContent = getRangeDescription()
  901. },
  902. type: 'range',
  903. value: sortedCommentIds.length - 1,
  904. })
  905.  
  906. let $button = /** @type {HTMLInputElement} */ (h('input', {
  907. id: 'timeTravelButton',
  908. onclick() {
  909. let referenceCommentId = sortedCommentIds[showNewCommentsAfter - 1]
  910. log(`manually highlighting ${howMany} comments since ${referenceCommentId}`)
  911. highlightNewComments(true, referenceCommentId)
  912. collapseThreadsWithoutNewComments(true, referenceCommentId)
  913. $timeTravelControl.remove()
  914. },
  915. type: 'button',
  916. value: 'highlight comments',
  917. }))
  918.  
  919. let $timeTravelControl = h('div', {
  920. id: 'timeTravel',
  921. }, h('div', null, $range), $button, $description)
  922.  
  923. $container.appendChild($timeTravelControl)
  924. }
  925.  
  926. /**
  927. * Collapses threads which don't have any comments newer than the given
  928. * comment id.
  929. * @param {boolean} collapse
  930. * @param {number} referenceCommentId
  931. */
  932. function collapseThreadsWithoutNewComments(collapse, referenceCommentId) {
  933. for (let i = 0; i < comments.length; i++) {
  934. let comment = comments[i]
  935. if (comment.isMuted) {
  936. // Skip muted comments and their replies as they're always hidden
  937. i += comment.childComments.length
  938. continue
  939. }
  940. if (!comment.isNewerThan(referenceCommentId) &&
  941. !comment.hasChildCommentsNewerThan(referenceCommentId)) {
  942. comment.toggleCollapsed(collapse)
  943. // Skip replies as we've already checked them
  944. i += comment.childComments.length
  945. }
  946. }
  947. }
  948.  
  949. function hideMutedUsers() {
  950. for (let i = 0; i < comments.length; i++) {
  951. let comment = comments[i]
  952. if (comment.isMuted) {
  953. comment.hide()
  954. // Skip replies as hide() already hid them
  955. i += comment.childComments.length
  956. }
  957. }
  958. }
  959.  
  960. /**
  961. * Highlights comments newer than the given comment id.
  962. * @param {boolean} highlight
  963. * @param {number} referenceCommentId
  964. */
  965. function highlightNewComments(highlight, referenceCommentId) {
  966. comments.forEach((comment) => {
  967. if (!comment.isMuted && comment.isNewerThan(referenceCommentId)) {
  968. comment.toggleHighlighted(highlight)
  969. }
  970. })
  971. }
  972.  
  973. function initComments() {
  974. let commentWrappers = /** @type {NodeListOf<HTMLTableRowElement>} */ (document.querySelectorAll('table.comment-tree tr.athing'))
  975. log('number of comment wrappers', commentWrappers.length)
  976.  
  977. let commentIndex = 0
  978. for (let $wrapper of commentWrappers) {
  979. let comment = new HNComment($wrapper, commentIndex++)
  980. comments.push(comment)
  981. if (!comment.isMuted && !comment.isDeleted) {
  982. commentsById[comment.id] = comment
  983. }
  984. }
  985.  
  986. let lastVisitMaxCommentId = lastVisit?.maxCommentId ?? -1
  987. for (let i = 0; i < comments.length; i++) {
  988. let comment = comments[i]
  989.  
  990. if (comment.isMuted) {
  991. mutedCommentCount++
  992. for (let j = i + 1; j <= i + comment.childComments.length; j++) {
  993. if (comments[j].isMuted) {
  994. mutedCommentCount++
  995. } else {
  996. replyToMutedCommentCount++
  997. }
  998. }
  999. // Skip child comments as we've already accounted for them
  1000. i += comment.childComments.length
  1001. // Don't consider muted comments or their replies when counting new
  1002. // comments, or add controls to them, as they'll all be hidden.
  1003. continue
  1004. }
  1005.  
  1006. if (!comment.isDeleted && comment.isNewerThan(lastVisitMaxCommentId)) {
  1007. newCommentCount++
  1008. }
  1009.  
  1010. comment.addControls()
  1011. }
  1012.  
  1013. maxCommentId = comments.map(comment => comment.id).sort().pop()
  1014. hasNewComments = lastVisit != null && newCommentCount > 0
  1015. }
  1016.  
  1017. // TODO Only store visit data when the item header is present (i.e. not a comment permalink)
  1018. // TODO Only store visit data for commentable items (a reply box / reply links are visible)
  1019. // TODO Clear any existing stored visit if the item is no longer commentable
  1020. function storePageViewData() {
  1021. storeVisit(itemId, new Visit({
  1022. commentCount,
  1023. maxCommentId,
  1024. time: new Date(),
  1025. }))
  1026. }
  1027. //#endregion
  1028.  
  1029. //#region Main
  1030. lastVisit = getLastVisit(itemId)
  1031.  
  1032. let $commentsLink = document.querySelector('span.subline > a[href^=item]')
  1033. if ($commentsLink && /^\d+/.test($commentsLink.textContent)) {
  1034. commentCount = Number($commentsLink.textContent.split(/\s/).shift())
  1035. } else {
  1036. warn('number of comments link not found')
  1037. }
  1038.  
  1039. configureCss()
  1040. initComments()
  1041. // Update display of any comments which were already collapsed by HN's own
  1042. // functionality, e.g. deleted comments
  1043. comments.filter(comment => comment.isCollapsed).forEach(comment => comment.updateDisplay(false))
  1044. hideMutedUsers()
  1045. if (hasNewComments && (autoHighlightNew || autoCollapseNotNew)) {
  1046. if (autoHighlightNew) {
  1047. highlightNewComments(true, lastVisit.maxCommentId)
  1048. }
  1049. if (autoCollapseNotNew) {
  1050. collapseThreadsWithoutNewComments(true, lastVisit.maxCommentId)
  1051. }
  1052. }
  1053. addPageControls()
  1054. storePageViewData()
  1055.  
  1056. log('page view data', {
  1057. autoHighlightNew,
  1058. commentCount,
  1059. mutedCommentCount,
  1060. replyToMutedCommentCount,
  1061. hasNewComments,
  1062. itemId,
  1063. lastVisit,
  1064. maxCommentId,
  1065. newCommentCount,
  1066. })
  1067.  
  1068. chrome.storage.onChanged.addListener((changes) => {
  1069. if ('hideReplyLinks' in changes) {
  1070. config.hideReplyLinks = changes['hideReplyLinks'].newValue
  1071. configureCss()
  1072. }
  1073. })
  1074. //#endregion
  1075. }
  1076. //#endregion
  1077.  
  1078. //#region Item list page
  1079. /**
  1080. * Each item on an item list page has the following structure:
  1081. *
  1082. * ```html
  1083. * <tr class="athing">…</td> (rank, upvote control, title/link and domain)
  1084. * <tr>
  1085. * <td>…</td> (spacer)
  1086. * <td class="subtext">
  1087. * <span class="subline">…</span> (item meta info)
  1088. * </td>
  1089. * </tr>
  1090. * <tr class="spacer">…</tr>
  1091. * ```
  1092. *
  1093. * Using the comment count stored when you visit a comment page, we'll display
  1094. * the number of new comments in the subtext section and provide a link which
  1095. * will automatically highlight new comments and collapse comment trees without
  1096. * new comments.
  1097. *
  1098. * For regular stories, the subtext element contains points, user, age (in
  1099. * a link to the comments page), flag/hide controls and finally the number of
  1100. * comments (in another link to the comments page). We'll look for the latter
  1101. * to detemine the current number of comments and the item id.
  1102. *
  1103. * For job postings, the subtext element only contains age (in
  1104. * a link to the comments page) and a hide control, so we'll try to ignore
  1105. * those.
  1106. */
  1107. function itemListPage() {
  1108. log('item list page')
  1109.  
  1110. //#region CSS
  1111. let $style = addStyle('list-dynamic')
  1112.  
  1113. function configureCss() {
  1114. $style.textContent = [
  1115. // Hide flag links
  1116. config.listPageFlagging == 'disabled' && `
  1117. .flag-sep, .flag-sep + a {
  1118. display: none;
  1119. }
  1120. `
  1121. ].filter(Boolean).map(dedent).join('\n')
  1122. }
  1123. //#endregion
  1124.  
  1125. //#region Functions
  1126. function confirmFlag(e) {
  1127. if (config.listPageFlagging != 'confirm') return
  1128. let title = e.target.closest('tr').previousElementSibling.querySelector('.titleline a')?.textContent || 'this item'
  1129. if (!confirm(`Are you sure you want to flag "${title}"?`)) {
  1130. e.preventDefault()
  1131. }
  1132. }
  1133. //#endregion
  1134.  
  1135. //#region Main
  1136. if (location.pathname != '/flagged') {
  1137. for (let $flagLink of document.querySelectorAll('span.subline > a[href^="flag"]')) {
  1138. // Wrap the '|' before flag links in an element so they can be hidden
  1139. $flagLink.previousSibling.replaceWith(h('span', {className: 'flag-sep'}, ' | '))
  1140. $flagLink.addEventListener('click', confirmFlag)
  1141. }
  1142. }
  1143.  
  1144. let commentLinks = /** @type {NodeListOf<HTMLAnchorElement>} */ (document.querySelectorAll('span.subline > a[href^="item?id="]:last-child'))
  1145. log('number of comments/discuss links', commentLinks.length)
  1146.  
  1147. let noCommentsCount = 0
  1148. let noLastVisitCount = 0
  1149.  
  1150. for (let $commentLink of commentLinks) {
  1151. let id = $commentLink.href.split('=').pop()
  1152.  
  1153. let commentCountMatch = /^(\d+)/.exec($commentLink.textContent)
  1154. if (commentCountMatch == null) {
  1155. noCommentsCount++
  1156. continue
  1157. }
  1158.  
  1159. let lastVisit = getLastVisit(id)
  1160. if (lastVisit == null) {
  1161. noLastVisitCount++
  1162. continue
  1163. }
  1164.  
  1165. let commentCount = Number(commentCountMatch[1])
  1166. if (commentCount <= lastVisit.commentCount) {
  1167. log(`${id} doesn't have any new comments`, lastVisit)
  1168. continue
  1169. }
  1170.  
  1171. $commentLink.insertAdjacentElement('afterend',
  1172. h('span', null,
  1173. ' (',
  1174. h('a', {
  1175. href: `item?shownew&id=${id}`,
  1176. style: {fontWeight: 'bold'},
  1177. },
  1178. commentCount - lastVisit.commentCount,
  1179. ' new'
  1180. ),
  1181. ')',
  1182. )
  1183. )
  1184. }
  1185.  
  1186. if (noCommentsCount > 0) {
  1187. log(`${noCommentsCount} item${s(noCommentsCount, " doesn't,s don't")} have any comments`)
  1188. }
  1189. if (noLastVisitCount > 0) {
  1190. log(`${noLastVisitCount} item${s(noLastVisitCount, " doesn't,s don't")} have a last visit stored`)
  1191. }
  1192.  
  1193. configureCss()
  1194.  
  1195. chrome.storage.onChanged.addListener((changes) => {
  1196. if ('listPageFlagging' in changes) {
  1197. config.listPageFlagging = changes['listPageFlagging'].newValue
  1198. configureCss()
  1199. }
  1200. })
  1201. //#endregion
  1202. }
  1203. //#endregion
  1204.  
  1205. //#region Profile page
  1206. function userProfilePage() {
  1207. log('user profile page')
  1208.  
  1209. let $userLink = /** @type {HTMLAnchorElement} */ (document.querySelector('a.hnuser'))
  1210. if ($userLink == null) {
  1211. warn('not a valid user')
  1212. return
  1213. }
  1214.  
  1215. let userId = $userLink.innerText
  1216. let $currentUserLink = /** @type {HTMLAnchorElement} */ (document.querySelector('a#me'))
  1217. let currentUser = $currentUserLink?.innerText ?? ''
  1218. let mutedUsers = getMutedUsers()
  1219. let userNotes = getUserNotes()
  1220. let $tbody = $userLink.closest('table').querySelector('tbody')
  1221. let editingNote = false
  1222.  
  1223. if (userId == currentUser || location.pathname.startsWith('/muted')) {
  1224. if (mutedUsers.size == 0) {
  1225. $tbody.appendChild(
  1226. h('tr', null,
  1227. h('td', {valign: 'top'}, 'muted:'),
  1228. h('td', null, 'No muted users.')
  1229. )
  1230. )
  1231. }
  1232.  
  1233. let first = 0
  1234. mutedUsers.forEach((mutedUserId) => {
  1235. $tbody.appendChild(
  1236. h('tr', null,
  1237. h('td', {valign: 'top'}, first++ == 0 ? 'muted:' : ''),
  1238. h('td', null,
  1239. h('a', {href: `user?id=${mutedUserId}`}, mutedUserId),
  1240. h('a', {
  1241. href: '#',
  1242. onClick: function(e) {
  1243. e.preventDefault()
  1244. if (mutedUsers.has(mutedUserId)) {
  1245. mutedUsers.delete(mutedUserId)
  1246. this.firstElementChild.innerText = 'mute'
  1247. }
  1248. else {
  1249. mutedUsers.add(mutedUserId)
  1250. this.firstElementChild.innerText = 'unmute'
  1251. }
  1252. setMutedUsers(mutedUsers)
  1253. }
  1254. },
  1255. ' (', h('u', null, 'unmute'), ')'
  1256. ),
  1257. userNotes[mutedUserId] ? ` - ${userNotes[mutedUserId].split(/\r?\n/)[0]}` : null,
  1258. )
  1259. )
  1260. )
  1261. })
  1262. }
  1263. else {
  1264. $tbody.append(
  1265. h('tr', null,
  1266. h('td'),
  1267. h('td', null,
  1268. h('a', {
  1269. href: '#',
  1270. onClick: function(e) {
  1271. e.preventDefault()
  1272. if (mutedUsers.has(userId)) {
  1273. mutedUsers.delete(userId)
  1274. this.firstElementChild.innerText = 'mute'
  1275. }
  1276. else {
  1277. mutedUsers.add(userId)
  1278. this.firstElementChild.innerText = 'unmute'
  1279. }
  1280. setMutedUsers(mutedUsers)
  1281. }
  1282. },
  1283. h('u', null, mutedUsers.has(userId) ? 'unmute' : 'mute')
  1284. )
  1285. )
  1286. ),
  1287. h('tr', null,
  1288. h('td', {vAlign: 'top'}, 'notes:'),
  1289. h('td', null,
  1290. userNotes[userId] ? h('div', {style: {whiteSpace: 'pre-line'}}, userNotes[userId] || '') : null,
  1291. h('button', {
  1292. style: {
  1293. marginTop: userNotes[userId] ? '4px' : '0'
  1294. },
  1295. onClick: function(e) {
  1296. e.preventDefault()
  1297. if (!editingNote) {
  1298. let notes = userNotes[userId] || ''
  1299. let $textArea = /** @type {HTMLTextAreaElement} */ (h('textarea', {
  1300. cols: 60,
  1301. value: notes,
  1302. style: {resize: 'none'},
  1303. onInput() {
  1304. autosizeTextArea(this)
  1305. },
  1306. // Use Ctrl/Cmd + Enter to save
  1307. onKeydown(e) {
  1308. if (e.key == 'Enter' && (e.ctrlKey || e.metaKey)) {
  1309. e.preventDefault()
  1310. this.parentElement.nextElementSibling.click()
  1311. }
  1312. }
  1313. }))
  1314. if (this.previousElementSibling) {
  1315. this.previousElementSibling.replaceWith(h('div', null, $textArea))
  1316. } else {
  1317. this.insertAdjacentElement('beforebegin', h('div', null, $textArea))
  1318. }
  1319. autosizeTextArea($textArea)
  1320. $textArea.focus()
  1321. $textArea.setSelectionRange(notes.length, notes.length)
  1322. this.innerText = 'save'
  1323. this.style.marginTop = '4px'
  1324. }
  1325. else {
  1326. let notes = this.previousElementSibling.firstElementChild.value
  1327. userNotes[userId] = notes
  1328. setUserNotes(userNotes)
  1329. if (notes) {
  1330. this.previousElementSibling.replaceWith(
  1331. h('div', {style: {whiteSpace: 'pre-line'}}, notes)
  1332. )
  1333. } else {
  1334. this.previousElementSibling.remove()
  1335. }
  1336. this.innerText = notes ? 'edit' : 'add'
  1337. this.style.marginTop = notes ? '4px' : '0'
  1338. }
  1339. editingNote = !editingNote
  1340. }
  1341. }, userNotes[userId] ? 'edit' : 'add')
  1342. )
  1343. ),
  1344. )
  1345. }
  1346. }
  1347. //#endregion
  1348.  
  1349. //#region Main
  1350. function main() {
  1351. log('config', config)
  1352.  
  1353. if (location.pathname.startsWith('/login')) {
  1354. log('login screen')
  1355. if (isSafari) {
  1356. log('trying to prevent Safari zooming in on the autofocused input')
  1357. addStyle('login-safari', `input[type="text"], input[type="password"] { font-size: 16px; }`)
  1358. setTimeout(() => {
  1359. document.querySelector('input[type="password"]').focus()
  1360. document.querySelector('input[type="text"]').focus()
  1361. })
  1362. }
  1363. return
  1364. }
  1365.  
  1366. if (location.pathname.startsWith('/muted')) {
  1367. document.documentElement.innerHTML = LOGGED_OUT_USER_PAGE
  1368. // Safari on macOS has a default dark background in dark mode
  1369. if (isSafari) {
  1370. addStyle('muted-safari', 'html { background-color: #fff; }')
  1371. }
  1372. }
  1373.  
  1374. tweakNav()
  1375.  
  1376. let path = location.pathname.slice(1)
  1377.  
  1378. if (/^($|active|ask|best($|\?)|flagged|front|hidden|invited|launches|news|newest|noobstories|pool|show|submitted|upvoted)/.test(path) ||
  1379. /^favorites/.test(path) && !location.search.includes('&comments=t')) {
  1380. itemListPage()
  1381. }
  1382. else if (/^item/.test(path)) {
  1383. commentPage()
  1384. }
  1385. else if (/^(user|muted)/.test(path)) {
  1386. userProfilePage()
  1387. }
  1388. }
  1389.  
  1390. if (
  1391. typeof GM == 'undefined' &&
  1392. typeof chrome != 'undefined' &&
  1393. typeof chrome.storage != 'undefined'
  1394. ) {
  1395. chrome.storage.local.get((storedConfig) => {
  1396. Object.assign(config, storedConfig)
  1397. main()
  1398. })
  1399. }
  1400. else {
  1401. main()
  1402. }
  1403. //#endregion