|
// ==UserScript== |
|
// @name HN + |
|
// @match https://*.ycombinator.com/* |
|
// @grant none |
|
// @version 2.5.0 |
|
// @author overflowy@riseup.net |
|
// @description Adds favicons to HN links, navigation menu for less known sections, and injects the about section into HN's native user hovercard |
|
// @inject-into content |
|
// ==/UserScript== |
|
|
|
// Add favicons functionality |
|
var favicons = document.getElementsByClassName("favicon"); |
|
if (!(favicons.length > 0)) { |
|
const articleLinks = document.querySelectorAll(".titleline > a"); |
|
for (let link of articleLinks) { |
|
const domain = new URL(link.href).hostname; |
|
const imageUrl = `https://icons.duckduckgo.com/ip3/${domain}.ico`; |
|
const imgEl = document.createElement("img"); |
|
imgEl.src = imageUrl; |
|
imgEl.className = "favicon"; |
|
imgEl.width = 14; |
|
imgEl.height = 14; |
|
imgEl.style.paddingRight = "0.25em"; |
|
imgEl.style.paddingLeft = "0.25em"; |
|
link.style.alignItems = "center"; |
|
link.style.display = "inline-flex"; |
|
link.style.justifyContent = "center"; |
|
link.prepend(imgEl); |
|
} |
|
} |
|
|
|
// Add navigation menu |
|
function createNavigationMenu() { |
|
// Look for the submit link in the main navigation |
|
const submitLink = document.querySelector('.pagetop a[href="submit"]'); |
|
if (!submitLink) return; |
|
|
|
// Create the menu button styled like other HN links |
|
const menuButton = document.createElement("a"); |
|
menuButton.href = "#"; |
|
menuButton.textContent = "extra"; |
|
menuButton.style.cssText = "color: #000000; text-decoration: none;"; |
|
|
|
// Create the dropdown menu |
|
const dropdown = document.createElement("div"); |
|
dropdown.className = "hn-dropdown"; |
|
dropdown.style.cssText = ` |
|
position: absolute; |
|
top: 100%; |
|
left: 0; |
|
background: #f6f6ef; |
|
border: 1px solid #ff6600; |
|
z-index: 1000; |
|
display: none; |
|
min-width: 120px; |
|
margin-top: 2px; |
|
font-size: 10pt; |
|
`; |
|
|
|
const menuItems = [ |
|
{ url: "https://news.ycombinator.com/shownew", label: "shownew" }, |
|
{ url: "https://news.ycombinator.com/pool", label: "pool" }, |
|
{ url: "https://news.ycombinator.com/best", label: "best" }, |
|
{ url: "https://news.ycombinator.com/asknew", label: "asknew" }, |
|
{ url: "https://news.ycombinator.com/bestcomments", label: "bestcomments" }, |
|
{ url: "https://news.ycombinator.com/active", label: "active" }, |
|
{ url: "https://news.ycombinator.com/noobcomments", label: "newcomments" }, |
|
{ url: "https://news.ycombinator.com/noobstories", label: "newstories" }, |
|
{ url: "https://news.ycombinator.com/newest", label: "newest" }, |
|
]; |
|
|
|
menuItems.forEach((item, index) => { |
|
const menuItem = document.createElement("a"); |
|
menuItem.href = item.url; |
|
menuItem.textContent = item.label; |
|
menuItem.style.cssText = ` |
|
display: block; |
|
padding: 4px 8px; |
|
color: #000000; |
|
text-decoration: none; |
|
font-size: 10pt; |
|
${index < menuItems.length - 1 ? "border-bottom: 1px solid #ff6600;" : ""} |
|
`; |
|
|
|
menuItem.onmouseover = function () { |
|
this.style.backgroundColor = "#ffffff"; |
|
}; |
|
menuItem.onmouseout = function () { |
|
this.style.backgroundColor = "transparent"; |
|
}; |
|
|
|
dropdown.appendChild(menuItem); |
|
}); |
|
|
|
// Create a wrapper for positioning |
|
const wrapper = document.createElement("span"); |
|
wrapper.style.position = "relative"; |
|
wrapper.style.display = "inline"; |
|
|
|
wrapper.appendChild(menuButton); |
|
wrapper.appendChild(dropdown); |
|
|
|
// Find the separator after submit and insert our menu |
|
let nextNode = submitLink.nextSibling; |
|
while ( |
|
nextNode && |
|
nextNode.nodeType === 3 && |
|
nextNode.textContent.trim() === "" |
|
) { |
|
nextNode = nextNode.nextSibling; |
|
} |
|
|
|
// Insert separator and the menu button after submit |
|
const separator = document.createTextNode(" | "); |
|
submitLink.parentNode.insertBefore(separator, nextNode); |
|
submitLink.parentNode.insertBefore(wrapper, nextNode); |
|
|
|
// Toggle menu visibility |
|
menuButton.addEventListener("click", function (e) { |
|
e.preventDefault(); |
|
dropdown.style.display = |
|
dropdown.style.display === "none" ? "block" : "none"; |
|
}); |
|
|
|
// Close menu when clicking outside |
|
document.addEventListener("click", function (e) { |
|
if (!wrapper.contains(e.target)) { |
|
dropdown.style.display = "none"; |
|
} |
|
}); |
|
} |
|
|
|
createNavigationMenu(); |
|
|
|
// Augment HN's native hovercard with the "about" section |
|
(function augmentHovercard() { |
|
// Style the native hovercard to have the HN-orange border like our old custom card. |
|
const style = document.createElement("style"); |
|
style.textContent = ` |
|
.hovercard { |
|
border: 1px solid #ff6600 !important; |
|
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.15); |
|
padding: 8px 10px !important; |
|
} |
|
.hovercard .hn-plus-about { |
|
padding-top: 6px; |
|
border-top: 1px solid #e0e0d8; |
|
max-height: 240px; |
|
overflow: auto; |
|
word-wrap: break-word; |
|
overflow-wrap: break-word; |
|
font-size: 9pt; |
|
line-height: 1.4; |
|
} |
|
.hovercard .hn-plus-about a { word-break: break-all; } |
|
.hovercard .hn-plus-loading { color: #828282; font-size: 9pt; margin-top: 4px; } |
|
`; |
|
document.documentElement.appendChild(style); |
|
|
|
// Cache about-sections across hovercards |
|
const aboutCache = new Map(); |
|
const inFlight = new Map(); |
|
|
|
function fetchAbout(username) { |
|
if (aboutCache.has(username)) { |
|
return Promise.resolve(aboutCache.get(username)); |
|
} |
|
if (inFlight.has(username)) { |
|
return inFlight.get(username); |
|
} |
|
const url = `https://news.ycombinator.com/user?id=${encodeURIComponent(username)}`; |
|
const p = fetch(url, { credentials: "same-origin" }) |
|
.then((r) => (r.ok ? r.text() : null)) |
|
.then((html) => { |
|
if (html === null) { |
|
aboutCache.set(username, null); |
|
inFlight.delete(username); |
|
return null; |
|
} |
|
const about = extractAbout(html); |
|
aboutCache.set(username, about); |
|
inFlight.delete(username); |
|
return about; |
|
}) |
|
.catch(() => { |
|
inFlight.delete(username); |
|
aboutCache.set(username, null); |
|
return null; |
|
}); |
|
inFlight.set(username, p); |
|
return p; |
|
} |
|
|
|
function extractAbout(html) { |
|
const doc = new DOMParser().parseFromString(html, "text/html"); |
|
const rows = doc.querySelectorAll("#hnmain table tr"); |
|
for (const row of rows) { |
|
const cells = row.querySelectorAll("td"); |
|
if (cells.length < 2) continue; |
|
if (cells[0].textContent.trim().toLowerCase() === "about:") { |
|
const cell = cells[1]; |
|
// Rewrite relative links to absolute and open in new tab |
|
cell.querySelectorAll("a[href]").forEach((a) => { |
|
const href = a.getAttribute("href"); |
|
if (href && !/^https?:\/\//i.test(href) && !href.startsWith("#")) { |
|
a.setAttribute( |
|
"href", |
|
"https://news.ycombinator.com/" + href.replace(/^\//, ""), |
|
); |
|
} |
|
a.setAttribute("target", "_blank"); |
|
a.setAttribute("rel", "noopener noreferrer"); |
|
}); |
|
const html = cell.innerHTML.trim(); |
|
return html.length > 0 ? html : null; |
|
} |
|
} |
|
return null; |
|
} |
|
|
|
function getUsernameFromHovercard(hovercard) { |
|
const link = hovercard.querySelector("a.hnuser"); |
|
if (!link) return null; |
|
// The username is the text content of the <a class="hnuser"> |
|
return link.textContent.trim(); |
|
} |
|
|
|
function injectAbout(hovercard) { |
|
// Skip if already injected |
|
if (hovercard.querySelector(".hn-plus-about, .hn-plus-loading")) return; |
|
|
|
const username = getUsernameFromHovercard(hovercard); |
|
if (!username) return; |
|
|
|
// Find the <table> inside the hovercard and append a new row beneath the karma row |
|
// so the about text appears between karma and the comments/mute/notes rows. |
|
// Simpler: just append a <div> after the table. |
|
const table = hovercard.querySelector("table"); |
|
if (!table) return; |
|
|
|
// Insert loading placeholder right after the table |
|
const loadingEl = document.createElement("div"); |
|
loadingEl.className = "hn-plus-loading"; |
|
loadingEl.textContent = "Loading about…"; |
|
table.insertAdjacentElement("afterend", loadingEl); |
|
|
|
fetchAbout(username).then((about) => { |
|
// Make sure this hovercard is still in the DOM |
|
if (!hovercard.isConnected) return; |
|
loadingEl.remove(); |
|
if (!about) return; |
|
const aboutEl = document.createElement("div"); |
|
aboutEl.className = "hn-plus-about"; |
|
aboutEl.innerHTML = about; |
|
table.insertAdjacentElement("afterend", aboutEl); |
|
}); |
|
} |
|
|
|
// Watch the document for hovercards appearing (HN toggles popover="auto" opacity/display). |
|
// We observe attribute changes on any .hovercard element since HN reuses them. |
|
const observer = new MutationObserver((mutations) => { |
|
for (const m of mutations) { |
|
// New nodes added (hovercard can be inserted on demand) |
|
m.addedNodes.forEach((node) => { |
|
if (node.nodeType !== 1) return; |
|
if (node.matches && node.matches(".hovercard")) { |
|
injectAbout(node); |
|
} else if (node.querySelectorAll) { |
|
node.querySelectorAll(".hovercard").forEach(injectAbout); |
|
} |
|
}); |
|
// Attribute changes on existing hovercards (e.g. opacity going from 0 to 1) |
|
if ( |
|
m.type === "attributes" && |
|
m.target.classList && |
|
m.target.classList.contains("hovercard") |
|
) { |
|
injectAbout(m.target); |
|
} |
|
} |
|
}); |
|
observer.observe(document.body, { |
|
childList: true, |
|
subtree: true, |
|
attributes: true, |
|
attributeFilter: ["style", "class"], |
|
}); |
|
|
|
// Catch any hovercards already in the DOM at script start |
|
document.querySelectorAll(".hovercard").forEach(injectAbout); |
|
})(); |