您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Highlight new comments, mute users, and other tweaks for Hacker News
// ==UserScript== // @name Comments Owl for Hacker News // @description Highlight new comments, mute users, and other tweaks for Hacker News // @namespace https://github.com/insin/comments-owl-for-hacker-news/ // @match https://news.ycombinator.com/* // @version 48 // ==/UserScript== let debug = false let isSafari = navigator.userAgent.includes('Safari/') && !/Chrom(e|ium)\//.test(navigator.userAgent) const HIGHLIGHT_COLOR = '#ffffde' const TOGGLE_HIDE = '[–]' const TOGGLE_SHOW = '[+]' const MUTED_USERS_KEY = 'mutedUsers' const USER_NOTES_KEY = 'userNotes' const LOGGED_OUT_USER_PAGE = `<head> <meta name="referrer" content="origin"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" type="text/css" href="news.css"> <link rel="shortcut icon" href="favicon.ico"> <title>Muted | Comments Owl for Hacker News</title> </head> <body> <center> <table id="hnmain" width="85%" cellspacing="0" cellpadding="0" border="0" bgcolor="#f6f6ef"> <tbody> <tr> <td bgcolor="#ff6600"> <table style="padding: 2px" width="100%" cellspacing="0" cellpadding="0" border="0"> <tbody> <tr> <td style="width: 18px; padding-right: 4px"> <a href="https://news.ycombinator.com"> <img src="y18.svg" style="border: 1px white solid; display: block" width="18" height="18"> </a> </td> <td style="line-height: 12pt; height: 10px"> <span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b> <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> </span> </td> <td style="text-align: right; padding-right: 4px"> <span class="pagetop"> <a href="login?goto=news">login</a> </span> </td> </tr> </tbody> </table> </td> </tr> <tr id="pagespace" title="Muted" style="height: 10px"></tr> <tr> <td> <table border="0"> <tbody> <tr class="athing"> <td valign="top">user:</td> <td> <a class="hnuser">anonymous comments owl user</a> </td> </tr> </tbody> </table> <br><br> </td> </tr> </tbody> </table> </center> </body>` //#region Config /** @type {import("./types").Config} */ let config = { addUpvotedToHeader: true, autoCollapseNotNew: true, autoHighlightNew: true, hideCommentsNav: false, hideJobsNav: false, hidePastNav: false, hideReplyLinks: false, hideSubmitNav: false, listPageFlagging: 'enabled', listPageHiding: 'enabled', makeSubmissionTextReadable: true, } //#endregion //#region Storage class Visit { constructor({commentCount, maxCommentId, time}) { /** @type {number} */ this.commentCount = commentCount /** @type {number} */ this.maxCommentId = maxCommentId /** @type {Date} */ this.time = time } toJSON() { return { c: this.commentCount, m: this.maxCommentId, t: this.time.getTime(), } } } Visit.fromJSON = function(obj) { return new Visit({ commentCount: obj.c, maxCommentId: obj.m, time: new Date(obj.t), }) } function getLastVisit(itemId) { let json = localStorage.getItem(itemId) if (json == null) return null return Visit.fromJSON(JSON.parse(json)) } function storeVisit(itemId, visit) { log('storing visit', visit) localStorage.setItem(itemId, JSON.stringify(visit)) } /** @returns {Set<string>} */ function getMutedUsers(json = localStorage[MUTED_USERS_KEY]) { return new Set(JSON.parse(json || '[]')) } /** @returns {Record<string, string>} */ function getUserNotes(json = localStorage[USER_NOTES_KEY]) { return JSON.parse(json || '{}') } function storeMutedUsers(mutedUsers) { localStorage[MUTED_USERS_KEY] = JSON.stringify(Array.from(mutedUsers)) } function storeUserNotes(userNotes) { localStorage[USER_NOTES_KEY] = JSON.stringify(userNotes) } //#endregion //#region Utility functions /** * @param {string} role * @param {...string} css */ function addStyle(role, ...css) { let $style = document.createElement('style') $style.dataset.insertedBy = 'comments-owl' $style.dataset.role = role if (css.length > 0) { $style.textContent = css.filter(Boolean).map(dedent).join('\n') } document.querySelector('head').appendChild($style) return $style } const autosizeTextArea = (() => { /** @type {Number} */ let textAreaPadding return function autosizeTextarea($textArea) { if (textAreaPadding == null) { textAreaPadding = Number(getComputedStyle($textArea).paddingTop.replace('px', '')) * 2 } $textArea.style.height = '0px' $textArea.style.height = $textArea.scrollHeight + textAreaPadding + 'px' } })() function checkbox(attributes, label) { return h('label', null, h('input', { style: {verticalAlign: 'middle'}, type: 'checkbox', ...attributes, }), ' ', label, ) } /** * @param {string} str * @return {string} */ function dedent(str) { str = str.replace(/^[ \t]*\r?\n/, '') let indent = /^[ \t]+/m.exec(str) if (indent) str = str.replace(new RegExp('^' + indent[0], 'gm'), '') return str.replace(/(\r?\n)[ \t]+$/, '$1') } /** * Create an element. * @param {string} tagName * @param {{[key: string]: any}} [attributes] * @param {...any} children * @returns {HTMLElement} */ function h(tagName, attributes, ...children) { let $el = document.createElement(tagName) if (attributes) { for (let [prop, value] of Object.entries(attributes)) { if (prop.indexOf('on') === 0) { $el.addEventListener(prop.slice(2).toLowerCase(), value) } else if (prop.toLowerCase() == 'style') { for (let [styleProp, styleValue] of Object.entries(value)) { $el.style[styleProp] = styleValue } } else { $el[prop] = value } } } for (let child of children) { if (child == null || child === false) { continue } if (child instanceof Node) { $el.appendChild(child) } else { $el.insertAdjacentText('beforeend', String(child)) } } return $el } function log(...args) { if (debug) { console.log('🦉', ...args) } } function warn(...args) { if (debug) { console.log('❗', ...args) } } /** * @param {number} count * @param {string} suffixes * @returns {string} */ function s(count, suffixes = ',s') { if (!suffixes.includes(',')) { suffixes = `,${suffixes}` } return suffixes.split(',')[count === 1 ? 0 : 1] } /** * @param {HTMLElement} $el * @param {boolean} hidden */ function toggleDisplay($el, hidden) { $el.classList.toggle('noshow', hidden) // We need to enforce display setting as the page's own script expands all // comments on page load. $el.style.display = hidden ? 'none' : '' } /** * @param {HTMLElement} $el * @param {boolean} hidden */ function toggleVisibility($el, hidden) { $el.classList.toggle('nosee', hidden) // We need to enforce visibility setting as the page's own script expands // all comments on page load. $el.style.visibility = hidden ? 'hidden' : 'visible' } //#endregion //#region Navigation function tweakNav() { let $pageTop = document.querySelector('span.pagetop') if (!$pageTop) { warn('pagetop not found') return } //#region CSS addStyle('nav-static', ` .desktopnav { display: inline; } .mobilenav { display: none; } @media only screen and (min-width : 300px) and (max-width : 750px) { .desktopnav { display: none; } .mobilenav { display: revert; } } `) let $style = addStyle('nav-dynamic') function configureCss() { let hideNavSelectors = [ config.hidePastNav && 'span.past-sep, span.past-sep + a', config.hideCommentsNav && 'span.comments-sep, span.comments-sep + a', config.hideJobsNav && 'span.jobs-sep, span.jobs-sep + a', config.hideSubmitNav && 'span.submit-sep, span.submit-sep + a', !config.addUpvotedToHeader && 'span.upvoted-sep, span.upvoted-sep + a', ].filter(Boolean) $style.textContent = hideNavSelectors.length == 0 ? '' : dedent(` ${hideNavSelectors.join(',\n')} { display: none; } `) } //#endregion //#region Main // Add a 'muted' link next to 'login' for logged-out users let $loginLink = document.querySelector('span.pagetop a[href^="login"]') if ($loginLink) { $loginLink.parentElement.append( h('a', {href: `muted`}, 'muted'), ' | ', $loginLink, ) } // Add /upvoted if we're not on it and the user is logged in if (!location.pathname.startsWith('/upvoted')) { let $userLink = document.querySelector('span.pagetop a[href^="user?id"]') if ($userLink) { let $submit = $pageTop.querySelector('a[href="submit"]') $submit.insertAdjacentElement('afterend', h('a', {href: `upvoted?id=${$userLink.textContent}`}, 'upvoted')) $submit.insertAdjacentElement('afterend', h('span', {className: 'upvoted-sep'}, ' | ')) } } // Wrap separators in elements so they can be used to hide items Array.from($pageTop.childNodes) .filter(n => n.nodeType == Node.TEXT_NODE && n.nodeValue == ' | ') .forEach(n => n.replaceWith(h('span', {className: `${n.nextSibling?.textContent}-sep`}, ' | '))) // Create a new row for mobile nav let $mobileNav = /** @type {HTMLTableCellElement} */ ($pageTop.parentElement.cloneNode(true)) $mobileNav.querySelector('b')?.remove() $mobileNav.colSpan = 3 $pageTop.closest('tbody').append(h('tr', {className: 'mobilenav'}, $mobileNav)) // Move everything after b.hnname into a desktop nav wrapper $pageTop.appendChild(h('span', {className: 'desktopnav'}, ...Array.from($pageTop.childNodes).slice(1))) configureCss() chrome.storage.local.onChanged.addListener((changes) => { for (let [configProp, change] of Object.entries(changes)) { if (['hidePastNav', 'hideCommentsNav', 'hideJobsNav', 'hideSubmitNav', 'addUpvotedToHeader'].includes(configProp)) { config[configProp] = change.newValue configureCss() } } }) //#endregion } //#endregion //#region Comment page /** * Each comment on a comment page has the following structure: * * ```html * <tr class="athing"> (wrapper) * <td> * <table> * <tr> * <td class="ind"> * <img src="s.gif" height="1" width="123"> (indentation) * </td> * <td class="votelinks">…</td> (vote up/down controls) * <td class="default"> * <div style="margin-top:2px; margin-bottom:-10px;"> * <div class="comhead"> (meta bar: user, age and folding control) * … * <div class="comment"> * <span class="comtext"> (text and reply link) * ``` * * We want to be able to collapse comment trees which don't contain new comments * and highlight new comments, so for each wrapper we'll create a `HNComment` * object to manage this. * * Comments are rendered as a flat list of table rows, so we'll use the width of * the indentation spacer to determine which comments are descendants of a given * comment. * * Since we have to reimplement our own comment folding, we'll hide the built-in * folding controls and create new ones in a better position (on the left), with * a larger hitbox (larger font and an en dash [–] instead of a hyphen [-]). * * On each comment page view, we store the current comment count, the max * comment id on the page and the current time as the last visit time. */ function commentPage() { log('comment page') //#region CSS addStyle('comments-static', ` /* Hide default toggle and nav links */ a.togg { display: none; } .toggle { cursor: pointer; margin-right: 3px; background: transparent; border: 0; padding: 0; color: inherit; font-family: inherit; } /* Display the mute control on hover, unless the comment is collapsed */ .mute { display: none; } /* Prevent :hover causing double-tap on comment functionality in iOS Safari */ @media(hover: hover) and (pointer: fine) { tr.comtr:hover td.votelinks:not(.nosee) + td .mute { display: inline; } } /* Don't show notes on collapsed comments */ td.votelinks.nosee + td .note { display: none; } #timeTravel { margin-top: 1em; vertical-align: middle; } #timeTravelRange { width: 100%; } #timeTravelButton { margin-right: 1em; } @media only screen and (min-width: 300px) and (max-width: 750px) { td.votelinks:not(.nosee) + td .mute { display: inline; } /* Allow comments to go full-width */ .comment { max-width: unset; } /* Increase distance between upvote and downvote */ a[id^="down_"] { margin-top: 16px; } /* Increase hit-target */ .toggle { font-size: 14px; } #highlightControls label { display: block; } #highlightControls label + label { margin-top: .5rem; } #timeTravelRange { width: calc(100% - 32px); } } `) let $style = addStyle('comments-dynamic') function configureCss() { $style.textContent = [ config.hideReplyLinks && ` div.reply { margin-top: 8px; } div.reply p { display: none; } `, config.makeSubmissionTextReadable && ` div.toptext { color: #000; } `, ].filter(Boolean).map(dedent).join('\n') } //#endregion //#region Variables /** @type {boolean} */ let autoCollapseNotNew = config.autoCollapseNotNew || location.search.includes('?shownew') /** @type {boolean} */ let autoHighlightNew = config.autoHighlightNew || location.search.includes('?shownew') /** @type {HNComment[]} */ let comments = [] /** @type {Record<string, HNComment>} */ let commentsById = {} /** @type {boolean} */ let hasNewComments = false /** @type {string} */ let itemId = /id=(\d+)/.exec(location.search)[1] /** @type {Visit} */ let lastVisit /** @type {number} */ let maxCommentId /** @type {Set<string>} */ let mutedUsers = getMutedUsers() /** @type {Record<string, string>} */ let userNotes = getUserNotes() // Comment counts let commentCount = 0 let mutedCommentCount = 0 let newCommentCount = 0 let replyToMutedCommentCount = 0 //#endregion class HNComment { /** * returns {boolean} */ get isMuted() { return mutedUsers.has(this.user) } /** * @returns {HNComment[]} */ get childComments() { if (this._childComments == null) { this._childComments = [] for (let i = this.index + 1; i < comments.length; i++) { if (comments[i].indent <= this.indent) { break } this._childComments.push(comments[i]) } } return this._childComments } get collapsedChildrenText() { return this.childCommentCount == 0 ? '' : [ this.isDeleted ? '(' : ' | (', this.childCommentCount, ` child${s(this.childCommentCount, 'ren')})`, ].join('') } /** * @returns {HNComment[]} */ get nonMutedChildComments() { if (this._nonMutedChildComments == null) { let muteIndent = null this._nonMutedChildComments = this.childComments.filter(comment => { if (muteIndent != null) { if (comment.indent > muteIndent) { return false } muteIndent = null } if (comment.isMuted) { muteIndent = comment.indent return false } return true }) } return this._nonMutedChildComments } /** * returns {number} */ get childCommentCount() { return this.nonMutedChildComments.length } /** * @param {HTMLElement} $wrapper * @param {number} index */ constructor($wrapper, index) { /** @type {number} */ this.indent = Number( /** @type {HTMLImageElement} */ ($wrapper.querySelector('img[src="s.gif"]')).width) /** @type {number} */ this.index = index let $user = /** @type {HTMLElement} */ ($wrapper.querySelector('a.hnuser')) /** @type {string} */ this.user = $user?.innerText /** @type {HTMLElement} */ this.$comment = $wrapper.querySelector('div.comment') /** @type {HTMLElement} */ this.$topBar = $wrapper.querySelector('td.default > div') /** @type {HTMLElement} */ this.$voteLinks = $wrapper.querySelector('td.votelinks') /** @type {HTMLElement} */ this.$wrapper = $wrapper /** @private @type {HNComment[]} */ this._childComments = null /** @private @type {HNComment[]} */ this._nonMutedChildComments = null /** * The comment's id. * Will be `-1` for deleted comments. * @type {number} */ this.id = -1 /** * Some flagged comments are collapsed by default. * @type {boolean} */ this.isCollapsed = $wrapper.classList.contains('coll') /** * Comments whose text has been removed but are still displayed may have * their text replaced with [flagged], [dead] or similar - we'll take any * word in square brackets as indication of this. * @type {boolean} */ this.isDeleted = /^\s*\[\w+]\s*$/.test(this.$comment.firstChild.nodeValue) /** * The displayed age of the comment; `${n} minutes/hours/days ago`, or * `on ${date}` for older comments. * Will be blank for deleted comments. * @type {string} */ this.when = '' /** @type {HTMLElement} */ this.$childCount = null /** @type {HTMLElement} */ this.$comhead = this.$topBar.querySelector('span.comhead') /** @type {HTMLElement} */ this.$toggleControl = h('button', { className: 'toggle', onclick: () => this.toggleCollapsed(), }, this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE) if (!this.isDeleted) { let $permalink = /** @type {HTMLAnchorElement} */ (this.$topBar.querySelector('a[href^=item]')) this.id = Number($permalink.href.split('=').pop()) this.when = $permalink?.textContent.replace('minute', 'min') } } addControls() { // We want to use the comment meta bar for the folding control, so put // it back above the deleted comment placeholder. if (this.isDeleted) { this.$topBar.style.marginBottom = '4px' } this.$topBar.insertAdjacentText('afterbegin', ' ') this.$topBar.insertAdjacentElement('afterbegin', this.$toggleControl) this.$comhead.append(...[ // User note userNotes[this.user] && h('span', {className: 'note'}, ` | nb: ${userNotes[this.user].split(/\r?\n/)[0]}`, ), // Mute control this.user && h('span', {className: 'mute'}, ' | ', h('a', { href: `mute?id=${this.user}`, onclick: (e) => { e.preventDefault() this.mute() } }, 'mute')) ].filter(Boolean)) } mute() { mutedUsers = getMutedUsers() mutedUsers.add(this.user) storeMutedUsers(mutedUsers) // Invalidate non-muted child caches and update child counts on any // comments which have been collapsed. for (let i = 0; i < comments.length; i++) { let comment = comments[i] if (comment.isMuted) { i += comment.childComments.length continue } comment._nonMutedChildComments = null if (comment.$childCount) { comment.$childCount.textContent = comment.collapsedChildrenText } } hideMutedUsers() } /** * @param {boolean} updateChildren */ updateDisplay(updateChildren = true) { // Show/hide this comment, preserving display of the meta bar toggleDisplay(this.$comment, this.isCollapsed) if (this.$voteLinks) { toggleVisibility(this.$voteLinks, this.isCollapsed) } this.$toggleControl.textContent = this.isCollapsed ? TOGGLE_SHOW : TOGGLE_HIDE // Show/hide the number of child comments when collapsed if (this.childCommentCount > 0) { if (this.isCollapsed && this.$childCount == null) { this.$childCount = h('span', null, this.collapsedChildrenText) this.$comhead.appendChild(this.$childCount) } toggleDisplay(this.$childCount, !this.isCollapsed) } if (updateChildren) { for (let i = 0; i < this.nonMutedChildComments.length; i++) { let child = this.nonMutedChildComments[i] toggleDisplay(child.$wrapper, this.isCollapsed) if (child.isCollapsed) { i += child.childComments.length } } } } /** * Completely hides this comment and its replies. */ hide() { toggleDisplay(this.$wrapper, true) this.childComments.forEach((child) => toggleDisplay(child.$wrapper, true)) } /** * @param {number} commentId * @returns {boolean} */ hasChildCommentsNewerThan(commentId) { return this.nonMutedChildComments.some((comment) => comment.isNewerThan(commentId)) } /** * @param {number} commentId * @returns {boolean} */ isNewerThan(commentId) { return this.id > commentId } /** * @param {boolean} isCollapsed */ toggleCollapsed(isCollapsed = !this.isCollapsed) { this.isCollapsed = isCollapsed this.updateDisplay() } /** * @param {boolean} highlight */ toggleHighlighted(highlight) { this.$wrapper.style.backgroundColor = highlight ? HIGHLIGHT_COLOR : 'transparent' } } //#region Functions function addHighlightCommentsControl($container) { let $highlightComments = h('span', null, ' | ', h('a', { href: '#', onClick(e) { e.preventDefault() addTimeTravelCommentControls($container) $highlightComments.remove() }, }, 'highlight comments')) $container.querySelector('.subline')?.append($highlightComments) } /** * Adds checkboxes to toggle folding and highlighting when there are new * comments on a comment page. * @param {HTMLElement} $container */ function addNewCommentControls($container) { $container.appendChild( h('div', null, h('p', null, `${newCommentCount} new comment${s(newCommentCount)} since ${lastVisit.time.toLocaleString()}` ), h('div', {id: 'highlightControls'}, checkbox({ checked: autoHighlightNew, onclick: (e) => { highlightNewComments(e.target.checked, lastVisit.maxCommentId) }, }, 'highlight new comments'), ' ', checkbox({ checked: autoCollapseNotNew, onclick: (e) => { collapseThreadsWithoutNewComments(e.target.checked, lastVisit.maxCommentId) }, }, 'collapse threads without new comments'), ), ) ) } /** * Adds the appropriate page controls depending on whether or not there are * new comments or any comments at all. */ function addPageControls() { let $container = /** @type {HTMLElement} */ (document.querySelector('td.subtext')) if (!$container) { warn('no container found for page controls') return } if (hasNewComments) { addNewCommentControls($container) } else if (commentCount > 1) { addHighlightCommentsControl($container) } } /** * Adds a range control and button to show the last X new comments. */ function addTimeTravelCommentControls($container) { let sortedCommentIds = [] for (let i = 0; i < comments.length; i++) { let comment = comments[i] if (comment.isMuted) { // Skip muted comments and their replies as they're always hidden i += comment.childComments.length continue } sortedCommentIds.push(comment.id) } sortedCommentIds.sort() let showNewCommentsAfter = Math.max(0, sortedCommentIds.length - 1) let howMany = sortedCommentIds.length - showNewCommentsAfter function getRangeDescription() { let fromWhen = commentsById[sortedCommentIds[showNewCommentsAfter]].when // Older comments display `on ${date}` instead of a relative time if (fromWhen.startsWith(' on')) { fromWhen = fromWhen.replace(' on', 'since') } else { fromWhen = `from ${fromWhen}` } return `${howMany} ${fromWhen}` } let $description = h('span', null, getRangeDescription()) let $range = h('input', { id: 'timeTravelRange', max: sortedCommentIds.length - 1, min: 1, oninput(e) { showNewCommentsAfter = Number(e.target.value) howMany = sortedCommentIds.length - showNewCommentsAfter $description.textContent = getRangeDescription() }, type: 'range', value: sortedCommentIds.length - 1, }) let $button = /** @type {HTMLInputElement} */ (h('input', { id: 'timeTravelButton', onclick() { let referenceCommentId = sortedCommentIds[showNewCommentsAfter - 1] log(`manually highlighting ${howMany} comments since ${referenceCommentId}`) highlightNewComments(true, referenceCommentId) collapseThreadsWithoutNewComments(true, referenceCommentId) $timeTravelControl.remove() }, type: 'button', value: 'highlight comments', })) let $timeTravelControl = h('div', { id: 'timeTravel', }, h('div', null, $range), $button, $description) $container.appendChild($timeTravelControl) } /** * Collapses threads which don't have any comments newer than the given * comment id. * @param {boolean} collapse * @param {number} referenceCommentId */ function collapseThreadsWithoutNewComments(collapse, referenceCommentId) { for (let i = 0; i < comments.length; i++) { let comment = comments[i] if (comment.isMuted) { // Skip muted comments and their replies as they're always hidden i += comment.childComments.length continue } if (!comment.isNewerThan(referenceCommentId) && !comment.hasChildCommentsNewerThan(referenceCommentId)) { comment.toggleCollapsed(collapse) // Skip replies as we've already checked them i += comment.childComments.length } } } function hideMutedUsers() { for (let i = 0; i < comments.length; i++) { let comment = comments[i] if (comment.isMuted) { comment.hide() // Skip replies as hide() already hid them i += comment.childComments.length } } } /** * Highlights comments newer than the given comment id. * @param {boolean} highlight * @param {number} referenceCommentId */ function highlightNewComments(highlight, referenceCommentId) { comments.forEach((comment) => { if (!comment.isMuted && comment.isNewerThan(referenceCommentId)) { comment.toggleHighlighted(highlight) } }) } function initComments() { let commentWrappers = /** @type {NodeListOf<HTMLTableRowElement>} */ (document.querySelectorAll('table.comment-tree tr.athing')) log('number of comment wrappers', commentWrappers.length) let commentIndex = 0 for (let $wrapper of commentWrappers) { let comment = new HNComment($wrapper, commentIndex++) comments.push(comment) if (!comment.isMuted && !comment.isDeleted) { commentsById[comment.id] = comment } } let lastVisitMaxCommentId = lastVisit?.maxCommentId ?? -1 for (let i = 0; i < comments.length; i++) { let comment = comments[i] if (comment.isMuted) { mutedCommentCount++ for (let j = i + 1; j <= i + comment.childComments.length; j++) { if (comments[j].isMuted) { mutedCommentCount++ } else { replyToMutedCommentCount++ } } // Skip child comments as we've already accounted for them i += comment.childComments.length // Don't consider muted comments or their replies when counting new // comments, or add controls to them, as they'll all be hidden. continue } if (!comment.isDeleted && comment.isNewerThan(lastVisitMaxCommentId)) { newCommentCount++ } comment.addControls() } maxCommentId = comments.map(comment => comment.id).sort().pop() hasNewComments = lastVisit != null && newCommentCount > 0 } // TODO Only store visit data when the item header is present (i.e. not a comment permalink) // TODO Only store visit data for commentable items (a reply box / reply links are visible) // TODO Clear any existing stored visit if the item is no longer commentable function storePageViewData() { storeVisit(itemId, new Visit({ commentCount, maxCommentId, time: new Date(), })) } //#endregion //#region Main lastVisit = getLastVisit(itemId) let $commentsLink = document.querySelector('span.subline > a[href^=item]') if ($commentsLink && /^\d+/.test($commentsLink.textContent)) { commentCount = Number($commentsLink.textContent.split(/\s/).shift()) } else { warn('number of comments link not found') } configureCss() initComments() // Update display of any comments which were already collapsed by HN's own // functionality, e.g. deleted comments comments.filter(comment => comment.isCollapsed).forEach(comment => comment.updateDisplay(false)) hideMutedUsers() if (hasNewComments && (autoHighlightNew || autoCollapseNotNew)) { if (autoHighlightNew) { highlightNewComments(true, lastVisit.maxCommentId) } if (autoCollapseNotNew) { collapseThreadsWithoutNewComments(true, lastVisit.maxCommentId) } } addPageControls() storePageViewData() log('page view data', { autoHighlightNew, commentCount, mutedCommentCount, replyToMutedCommentCount, hasNewComments, itemId, lastVisit, maxCommentId, newCommentCount, }) chrome.storage.local.onChanged.addListener((changes) => { if ('hideReplyLinks' in changes) { config.hideReplyLinks = changes['hideReplyLinks'].newValue configureCss() } if ('makeSubmissionTextReadable' in changes) { config.makeSubmissionTextReadable = changes['makeSubmissionTextReadable'].newValue configureCss() } }) //#endregion } //#endregion //#region Item list page /** * Each item on an item list page has the following structure: * * ```html * <tr class="athing">…</td> (rank, upvote control, title/link and domain) * <tr> * <td>…</td> (spacer) * <td class="subtext"> * <span class="subline">…</span> (item meta info) * </td> * </tr> * <tr class="spacer">…</tr> * ``` * * Using the comment count stored when you visit a comment page, we'll display * the number of new comments in the subtext section and provide a link which * will automatically highlight new comments and collapse comment trees without * new comments. * * For regular stories, the subtext element contains points, user, age (in * a link to the comments page), flag/hide controls and finally the number of * comments (in another link to the comments page). We'll look for the latter * to detemine the current number of comments and the item id. * * For job postings, the subtext element only contains age (in * a link to the comments page) and a hide control, so we'll try to ignore * those. */ function itemListPage() { log('item list page') //#region CSS let $style = addStyle('list-dynamic') function configureCss() { $style.textContent = [ // Hide flag links config.listPageFlagging == 'disabled' && ` .flag-sep, .flag-sep + a { display: none; } `, // Hide hide links config.listPageHiding == 'disabled' && ` .hide-sep, .hide-sep + a { display: none; } ` ].filter(Boolean).map(dedent).join('\n') } //#endregion //#region Functions function confirmFlag(e) { if (config.listPageFlagging != 'confirm') return let title = e.target.closest('tr').previousElementSibling.querySelector('.titleline a')?.textContent || 'this item' if (!confirm(`Are you sure you want to flag "${title}"?`)) { e.stopPropagation() e.stopImmediatePropagation() e.preventDefault() return false } } function confirmHide(e) { if (config.listPageHiding != 'confirm') return let title = e.target.closest('tr').previousElementSibling.querySelector('.titleline a')?.textContent || 'this item' if (!confirm(`Are you sure you want to hide "${title}"?`)) { e.stopPropagation() e.stopImmediatePropagation() e.preventDefault() return false } } //#endregion //#region Main if (location.pathname != '/flagged') { for (let $flagLink of document.querySelectorAll('span.subline > a[href^="flag"]')) { // Wrap the '|' before flag links in an element so they can be hidden $flagLink.previousSibling.replaceWith(h('span', {className: 'flag-sep'}, ' | ')) $flagLink.addEventListener('click', confirmFlag, true) } } if (location.pathname != '/hidden') { for (let $hideLink of document.querySelectorAll('span.subline > a[href^="hide"]')) { // Wrap the '|' before hide links in an element so they can be hidden $hideLink.previousSibling.replaceWith(h('span', {className: 'hide-sep'}, ' | ')) $hideLink.addEventListener('click', confirmHide, true) } } let commentLinks = /** @type {NodeListOf<HTMLAnchorElement>} */ (document.querySelectorAll('span.subline > a[href^="item?id="]:last-child')) log('number of comments/discuss links', commentLinks.length) let noCommentsCount = 0 let noLastVisitCount = 0 for (let $commentLink of commentLinks) { let id = $commentLink.href.split('=').pop() let commentCountMatch = /^(\d+)/.exec($commentLink.textContent) if (commentCountMatch == null) { noCommentsCount++ continue } let lastVisit = getLastVisit(id) if (lastVisit == null) { noLastVisitCount++ continue } let commentCount = Number(commentCountMatch[1]) if (commentCount <= lastVisit.commentCount) { log(`${id} doesn't have any new comments`, lastVisit) continue } $commentLink.insertAdjacentElement('afterend', h('span', null, ' (', h('a', { href: `item?shownew&id=${id}`, style: {fontWeight: 'bold'}, }, commentCount - lastVisit.commentCount, ' new' ), ')', ) ) } if (noCommentsCount > 0) { log(`${noCommentsCount} item${s(noCommentsCount, " doesn't,s don't")} have any comments`) } if (noLastVisitCount > 0) { log(`${noLastVisitCount} item${s(noLastVisitCount, " doesn't,s don't")} have a last visit stored`) } configureCss() chrome.storage.local.onChanged.addListener((changes) => { if ('listPageFlagging' in changes) { config.listPageFlagging = changes['listPageFlagging'].newValue configureCss() } if ('listPageHiding' in changes) { config.listPageHiding = changes['listPageHiding'].newValue configureCss() } }) //#endregion } //#endregion //#region Profile page function userProfilePage() { log('user profile page') let $userLink = /** @type {HTMLAnchorElement} */ (document.querySelector('a.hnuser')) if ($userLink == null) { warn('not a valid user') return } let userId = $userLink.innerText let $currentUserLink = /** @type {HTMLAnchorElement} */ (document.querySelector('a#me')) let currentUser = $currentUserLink?.innerText ?? '' let mutedUsers = getMutedUsers() let userNotes = getUserNotes() let $table = $userLink.closest('table') if (userId == currentUser || location.pathname.startsWith('/muted')) { //#region Logged-in user's profile let $mutedUsers = createMutedUsers() function createMutedUsers() { if (mutedUsers.size == 0) { return h('tbody', null, h('tr', null, h('td', {valign: 'top'}, 'muted:'), h('td', null, 'No muted users.') ) ) } let first = 0 return h('tbody', null, ...Array.from(mutedUsers).map((mutedUserId) => h('tr', null, h('td', {valign: 'top'}, first++ == 0 ? 'muted:' : ''), h('td', null, h('a', {href: `user?id=${mutedUserId}`}, mutedUserId), h('a', { href: '#', onClick: function(e) { e.preventDefault() mutedUsers = getMutedUsers() mutedUsers.delete(mutedUserId) storeMutedUsers(mutedUsers) replaceMutedUsers() } }, ' (', h('u', null, 'unmute'), ')' ), userNotes[mutedUserId] ? ` - ${userNotes[mutedUserId].split(/\r?\n/)[0]}` : null, ), )) ) } function replaceMutedUsers() { let $newMutedUsers = createMutedUsers() $mutedUsers.replaceWith($newMutedUsers) $mutedUsers = $newMutedUsers } $table.append($mutedUsers) window.addEventListener('storage', (e) => { if (e.storageArea !== localStorage || e.newValue == null || e.key != MUTED_USERS_KEY && e.key != USER_NOTES_KEY) { return } if (e.key == MUTED_USERS_KEY) { mutedUsers = getMutedUsers(e.newValue) } else if (e.key == USER_NOTES_KEY) { userNotes = getUserNotes(e.newValue) } replaceMutedUsers() }) //#endregion } else { //#region Other user profile addStyle('profile-static', ` .saved { color: #000; opacity: 0; } .saved.show { animation: flash 2s forwards; } @keyframes flash { from { opacity: 0; } 15% { opacity: 1; animation-timing-function: ease-in; } 75% { opacity: 1; } to { opacity: 0; animation-timing-function: ease-out; } } .notes { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; } `) function getMutedStatusText() { return mutedUsers.has(userId) ? 'unmute' : 'mute' } function getUserNote() { return userNotes[userId] || '' } function userHasNote() { return userNotes.hasOwnProperty(userId) } function saveNotes() { userNotes = getUserNotes() let note = $textArea.value.trim() // Don't save initial blanks or duplicates if (userNotes[userId] == note || note == '' && !userHasNote()) return userNotes[userId] = $textArea.value.trim() storeUserNotes(userNotes) if ($saved.classList.contains('show')) { $saved.classList.remove('show') $saved.offsetHeight } $saved.classList.add('show') } let $textArea = /** @type {HTMLTextAreaElement} */ (h('textarea', { cols: 60, value: userNotes[userId] || '', className: 'notes', style: {resize: 'none'}, onInput() { autosizeTextArea(this) }, onKeydown(e) { // Save on Use Ctrl+Enter / Cmd+Return if (e.key == 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault() saveNotes() } }, onBlur() { saveNotes() } })) let $muted = h('u', null, getMutedStatusText()) let $saved = h('span', {className: 'saved'}, 'saved') $table.querySelector('tbody').append( h('tr', null, h('td'), h('td', null, h('a', { href: '#', onClick: function(e) { e.preventDefault() if (mutedUsers.has(userId)) { mutedUsers = getMutedUsers() mutedUsers.delete(userId) this.firstElementChild.innerText = 'mute' } else { mutedUsers = getMutedUsers() mutedUsers.add(userId) this.firstElementChild.innerText = 'unmute' } storeMutedUsers(mutedUsers) } }, $muted ) ) ), h('tr', null, h('td', {vAlign: 'top'}, 'notes:'), h('td', {className: 'notes'}, $textArea, $saved), ), ) autosizeTextArea($textArea) window.addEventListener('storage', (e) => { if (e.storageArea !== localStorage || e.newValue == null) return if (e.key == MUTED_USERS_KEY) { mutedUsers = getMutedUsers(e.newValue) if ($muted.textContent != getMutedStatusText()) { $muted.textContent = getMutedStatusText() } } else if (e.key == USER_NOTES_KEY) { userNotes = getUserNotes(e.newValue) if (userHasNote() && $textArea.value.trim() != getUserNote()) { $textArea.value = getUserNote() } } }) //#endregion } } //#endregion //#region Main function main() { log('config', config) if (location.pathname.startsWith('/login')) { log('login screen') if (isSafari) { log('trying to prevent Safari zooming in on the autofocused input') addStyle('login-safari', `input[type="text"], input[type="password"] { font-size: 16px; }`) setTimeout(() => { document.querySelector('input[type="password"]').focus() document.querySelector('input[type="text"]').focus() }) } return } if (location.pathname.startsWith('/muted')) { document.documentElement.innerHTML = LOGGED_OUT_USER_PAGE // Safari on macOS has a default dark background in dark mode if (isSafari) { addStyle('muted-safari', 'html { background-color: #fff; }') } } tweakNav() let path = location.pathname.slice(1) if (/^($|active|ask|best($|\?)|flagged|front|hidden|invited|launches|news|newest|noobstories|pool|show|submitted|upvoted)/.test(path) || /^favorites/.test(path) && !location.search.includes('&comments=t')) { itemListPage() } else if (/^item/.test(path)) { commentPage() } else if (/^(user|muted)/.test(path)) { userProfilePage() } } if ( typeof GM == 'undefined' && typeof chrome != 'undefined' && typeof chrome.storage != 'undefined' ) { chrome.storage.local.get((storedConfig) => { Object.assign(config, storedConfig) main() }) } else { main() } //#endregion