// ==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