Hacker News Plus

5 min read Original article ↗
// ==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); })();