Greasy Fork 支持简体中文。

Comments Owl for Hacker News

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

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