Comments Owl for Hacker News

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

目前为 2023-07-17 提交的版本,查看 最新版本

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