// ==UserScript== // @name HNewhere // @namespace https://github.com/twalichiewicz/HNewhere // @version 1.4.6 // @updateURL https://raw.githubusercontent.com/twalichiewicz/HNewhere/main/HNewhere.user.js // @downloadURL https://raw.githubusercontent.com/twalichiewicz/HNewhere/main/HNewhere.user.js // @homepageURL https://github.com/twalichiewicz/HNewhere // @supportURL https://github.com/twalichiewicz/HNewhere/issues // @description Hacker News comments sidebar for any article // @include http://* // @include https://* // @exclude http://localhost/* // @exclude https://localhost/* // @exclude https://www.google.com/* // @exclude https://www.google.*/* // @exclude https://chatgpt.com/* // @exclude https://*.google.com/* // @exclude https://accounts.google.com/* // @exclude https://mail.google.com/* // @exclude https://mail.*.*/* // @exclude https://*.bank.com/* // @exclude https://*.googleusercontent.com/* // @exclude https://*.doubleclick.net/* // @exclude https://*.facebook.com/* // @exclude https://*.twitter.com/* // @grant GM.getValue // @grant GM.setValue // @grant GM.xmlHttpRequest // @connect hacker-news.firebaseio.com // @connect hn.algolia.com // @run-at document-end // @noframes // ==/UserScript== (function () { "use strict"; const STORAGE = { width: "hn_width", position: "hn_button_position", last: "hn_last", }; let sidebar = null; let opening = false; // ------------------------- // Storage // ------------------------- async function save(key, value) { await GM.setValue(key, value); } async function load(key, fallback) { try { return await GM.getValue(key, fallback); } catch { return fallback; } } // ------------------------- // Network // ------------------------- function request(url) { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: "GET", url: url, onload: function (response) { try { resolve(JSON.parse(response.responseText)); } catch { resolve(null); } }, onerror: function () { resolve(null); }, }); }); } async function getItem(id) { return request( "https://hacker-news.firebaseio.com/v0/item/" + id + ".json", ); } async function findHN(url) { const target = normalizeURL(url); const queries = [url, target]; const matches = new Map(); for (const query of queries) { const result = await request( "https://hn.algolia.com/api/v1/search?tags=story&restrictSearchableAttributes=url&hitsPerPage=100&query=" + encodeURIComponent(query), ); if (!result || !result.hits) continue; result.hits.forEach((item) => { if (normalizeURL(item.url) === target) { matches.set(item.objectID, item); } }); } return [...matches.values()].sort( (a, b) => b.created_at_i - a.created_at_i, ); } // ------------------------- // Helpers // ------------------------- function normalizeURL(url) { try { const u = new URL(url); [ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid", "gclid", ].forEach((param) => u.searchParams.delete(param)); return ( u.hostname + u.pathname.replace(/\/$/, "") + u.search ).toLowerCase(); } catch { return ""; } } function sanitizeHTML(html) { const template = document.createElement("template"); template.innerHTML = html || ""; template.content .querySelectorAll("script, iframe, object, embed") .forEach((el) => el.remove()); template.content.querySelectorAll("*").forEach((el) => { for (const attr of [...el.attributes]) { if (attr.name.startsWith("on")) { el.removeAttribute(attr.name); } } el.removeAttribute("style"); for (const attr of ["href", "src"]) { const value = el.getAttribute(attr); if (value && /^(javascript|data):/i.test(value)) { el.removeAttribute(attr); } } }); return template.innerHTML; } function escapeHTML(value) { return String(value || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function timeAgo(timestamp) { if (!timestamp) return ""; const seconds = Math.floor(Date.now() / 1000 - timestamp); if (seconds < 60) return "just now"; const minutes = Math.floor(seconds / 60); if (minutes < 60) return minutes + " minutes ago"; const hours = Math.floor(minutes / 60); if (hours < 24) return hours + " hours ago"; const days = Math.floor(hours / 24); return days === 1 ? "1 day ago" : days + " days ago"; } function isMobile() { return window.matchMedia("(max-width: 700px)").matches; } function clampButtonPosition(button) { const maxX = window.innerWidth - button.offsetWidth; const maxY = window.innerHeight - button.offsetHeight; const currentX = parseInt(button.style.left || button.offsetLeft, 10); const currentY = parseInt(button.style.top || button.offsetTop, 10); const x = Math.max(0, Math.min(currentX, maxX)); const y = Math.max(0, Math.min(currentY, maxY)); button.style.left = x + "px"; button.style.top = y + "px"; button.style.right = "auto"; } // ------------------------- // Popup helpers // ------------------------- function openHNWindow(url) { window.open( url, "hn_popup", "width=760,height=700,resizable=yes,scrollbars=yes", ); } function replyURL(comment, storyID) { return ( "https://news.ycombinator.com/reply?id=" + comment.id + "&goto=item%3Fid%3D" + storyID + "%23" + comment.id ); } function commentURL(storyID) { return "https://news.ycombinator.com/item?id=" + storyID; } // ------------------------- // Restore button // ------------------------- async function applyButtonPosition(button) { const saved = await load(STORAGE.position, null); if (!saved) return; const maxX = window.innerWidth - button.offsetWidth; const maxY = window.innerHeight - button.offsetHeight; button.style.left = Math.max(0, Math.min(saved.x, maxX)) + "px"; button.style.top = Math.max(0, Math.min(saved.y, maxY)) + "px"; button.style.right = "auto"; } function makeButtonDraggable(button) { let dragging = false; let moved = false; let suppressClick = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; const clampPosition = () => { const maxX = window.innerWidth - button.offsetWidth; const maxY = window.innerHeight - button.offsetHeight; button.style.left = Math.max(0, Math.min(button.offsetLeft, maxX)) + "px"; button.style.top = Math.max(0, Math.min(button.offsetTop, maxY)) + "px"; button.style.right = "auto"; }; window.addEventListener("resize", clampPosition); button.addEventListener("pointerdown", (event) => { dragging = true; moved = false; startX = event.clientX; startY = event.clientY; const rect = button.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; button.setPointerCapture(event.pointerId); }); button.addEventListener("pointermove", (event) => { if (!dragging) return; const deltaX = event.clientX - startX; const deltaY = event.clientY - startY; if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) { moved = true; } button.style.left = Math.min( Math.max(0, startLeft + deltaX), window.innerWidth - button.offsetWidth, ) + "px"; button.style.top = Math.min( Math.max(0, startTop + deltaY), window.innerHeight - button.offsetHeight, ) + "px"; button.style.right = "auto"; }); button.addEventListener("pointerup", (event) => { if (!dragging) return; dragging = false; if (moved) { suppressClick = true; save(STORAGE.position, { x: button.offsetLeft, y: button.offsetTop, }); // Clear after the browser has finished dispatching click. requestAnimationFrame(() => { suppressClick = false; }); } if (button.hasPointerCapture(event.pointerId)) { button.releasePointerCapture(event.pointerId); } }); button.addEventListener("click", (event) => { if (suppressClick) { event.preventDefault(); event.stopImmediatePropagation(); } }); return () => suppressClick; } async function createRestoreButton() { let button = document.getElementById("hn-restore-button"); if (button) return button; button = document.createElement("button"); button.id = "hn-restore-button"; button.textContent = "HN"; button.style.cssText = ` position:fixed; top:12px; right:12px; z-index:2147483647; background:#ff6600; color:white; border:none; border-radius:3px; padding:4px 8px; font-family:Verdana,sans-serif; font-size:11px; font-weight:bold; cursor:pointer; box-shadow:0 1px 4px rgba(0,0,0,.25); -webkit-tap-highlight-color: transparent; `; document.body.appendChild(button); await applyButtonPosition(button); makeButtonDraggable(button); return button; } async function createCollapsedButton(stories) { let button = document.getElementById("hn-collapse-button"); if (button) return button; button = document.createElement("button"); button.id = "hn-collapse-button"; button.textContent = "HN"; button.style.cssText = ` position:fixed; top:12px; right:12px; z-index:2147483647; background:#ff6600; color:white; border:none; border-radius:3px; padding:4px 8px; font-family:Verdana,sans-serif; font-size:11px; font-weight:bold; cursor:pointer; box-shadow:0 1px 4px rgba(0,0,0,.25); user-select:none; touch-action:none; -webkit-tap-highlight-color: transparent; `; document.body.appendChild(button); await applyButtonPosition(button); const wasMoved = makeButtonDraggable(button); button.onclick = () => { if (wasMoved()) return; button.remove(); openSidebar(stories).catch(console.error); }; return button; } // ------------------------- // Sidebar // ------------------------- async function createSidebar() { if (sidebar) { sidebar._cleanup?.(); sidebar.remove(); sidebar = null; } const savedWidth = await load(STORAGE.width, 420); const width = Math.min(Math.max(savedWidth, 280), window.innerWidth * 0.8); const host = document.createElement("div"); document.body.appendChild(host); const shadow = host.attachShadow({ mode: "open", }); shadow.innerHTML = `
HNewhere
Loading...
`; const panel = shadow.querySelector("#panel"); let resizing = false; let startX = 0; let startWidth = 0; panel.addEventListener("mousemove", (e) => { if (e.offsetX < 8) { panel.style.cursor = "col-resize"; } else if (!resizing) { panel.style.cursor = "default"; } }); panel.addEventListener("mouseleave", () => { if (!resizing) { panel.style.cursor = "default"; } }); panel.addEventListener("mousedown", (e) => { if (e.offsetX >= 8) return; resizing = true; startX = e.clientX; startWidth = panel.offsetWidth; document.body.style.userSelect = "none"; document.body.style.cursor = "col-resize"; e.preventDefault(); }); let resizeTimer; const onMouseMove = (e) => { if (!resizing) return; const delta = startX - e.clientX; const newWidth = Math.min( Math.max(startWidth + delta, 280), window.innerWidth * 0.8, ); panel.style.width = newWidth + "px"; clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { if (!destroyed) { save(STORAGE.width, newWidth); } }, 250); }; const onMouseUp = () => { if (!resizing) return; resizing = false; document.body.style.userSelect = ""; document.body.style.cursor = ""; panel.style.cursor = "default"; }; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); let destroyed = false; host._cleanup = () => { destroyed = true; clearTimeout(resizeTimer); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); }; shadow.querySelector("#minimize").onclick = async () => { host.style.display = "none"; const restore = await createRestoreButton(); restore.onclick = () => { host.style.display = ""; restore.remove(); }; }; document .querySelectorAll("#hn-restore-button, #hn-collapse-button") .forEach((button) => button.remove()); sidebar = host; return { shadow, body: shadow.querySelector("#comments"), }; } // ------------------------- // Story rendering // ------------------------- function renderStory(story, container, options = {}) { const { multiple = false, stories = [] } = options; const url = story.url || "https://news.ycombinator.com/item?id=" + story.id; const wrapper = document.createElement("div"); wrapper.innerHTML = `
${escapeHTML(story.title)}
${story.score || 0} points by ${escapeHTML(story.by || "")} | ${timeAgo(story.time)}

`; const storyElement = wrapper.firstElementChild; container.appendChild(storyElement); storyElement.querySelector(".add-comment").onclick = () => { openHNWindow(commentURL(story.id)); }; } // ------------------------- // Comment rendering // ------------------------- async function renderComment(id, container, storyID) { const comment = await getItem(id); if (!comment || comment.deleted || comment.dead) return; const div = document.createElement("div"); div.className = "comment"; const replies = comment.kids || []; const reply = replyURL(comment, storyID); div.innerHTML = `
${escapeHTML(comment.by || "anonymous")} ${timeAgo(comment.time)} | reply [–]
${sanitizeHTML(comment.text) || ""}
`; container.appendChild(div); const children = div.querySelector(".children"); const toggle = div.querySelector(".toggle"); toggle.onclick = () => { children.classList.toggle("hidden"); toggle.textContent = children.classList.contains("hidden") ? "[+]" : "[–]"; }; const replyButton = div.querySelector(".reply-link"); replyButton.onclick = function (event) { event.preventDefault(); openHNWindow(reply); }; for (let i = 0; i < replies.length; i++) { await renderComment(replies[i], children, storyID); if (i > 0 && i % 10 === 0) { await new Promise(requestAnimationFrame); } } } // ------------------------- // Discussion loading // ------------------------- async function renderSingleDiscussion(story, ui) { ui.body.innerHTML = ""; renderStory(story, ui.body); const comments = document.createElement("div"); comments.className = "top-level-comments"; ui.body.appendChild(comments); for (const child of story.kids || []) { await renderComment(child, comments, story.id); } } async function renderBlendedDiscussion(stories, ui) { ui.body.innerHTML = ""; for (const story of stories) { const section = document.createElement("div"); section.className = "submission"; ui.body.appendChild(section); renderStory(story, section, { multiple: true, stories, }); const comments = document.createElement("div"); comments.className = "top-level-comments"; section.appendChild(comments); for (const child of story.kids || []) { await renderComment(child, comments, story.id); } } } async function loadStories(stories) { const loaded = []; for (const story of normalizeStories(stories)) { const item = await getItem(story.objectID); if (item) { loaded.push(item); } } loaded.sort((a, b) => b.time - a.time); return loaded; } // ------------------------- // Open sidebar // ------------------------- function normalizeStories(stories) { return stories.map((story) => typeof story === "string" ? { objectID: story } : story, ); } async function openSidebar(stories) { if (opening) return; opening = true; try { const ui = await createSidebar(); const loaded = await loadStories(stories); if (!loaded.length) { throw new Error("No HN stories could be loaded"); } if (loaded.length === 1) { await renderSingleDiscussion(loaded[0], ui); } else { await renderBlendedDiscussion(loaded, ui); } } catch (e) { console.error(e); } finally { opening = false; } } // ------------------------- // Hacker News click tracking // ------------------------- function setupHNListener() { document.addEventListener( "click", async function (event) { try { const link = event.target.closest("a"); if (!link) return; const row = link.closest("tr.athing"); if (!row) return; if (!link.closest(".titleline")) return; const id = row.id; if (!id) return; console.log("Saving HN story:", id, link.href); await save(STORAGE.last, { url: link.href, ids: [id], timestamp: Date.now(), }); } catch (e) { console.error("Failed saving HN story:", e); } }, true, ); } // ------------------------- // URL helpers // ------------------------- function sameURL(a, b) { return normalizeURL(a) === normalizeURL(b); } // ------------------------- // Initialization // ------------------------- async function init() { console.log("HNewhere sidebar loaded", location.href); // On HN, only record clicked stories. if (location.hostname === "news.ycombinator.com") { setupHNListener(); return; } // Check if we arrived here by clicking // a story from Hacker News. let last = await load(STORAGE.last, null); if (last && Date.now() - last.timestamp > 300000) { await save(STORAGE.last, null); last = null; } console.log("Stored HN click:", last); console.log("Current URL:", location.href); console.log("Same URL:", last && sameURL(last.url, location.href)); console.log("Age:", last ? Date.now() - last.timestamp : null); if ( last && sameURL(last.url, location.href) && Date.now() - last.timestamp < 300000 ) { console.log("Opening HN discussion from click:", last.ids); await save(STORAGE.last, null); await openSidebar( last.ids.map((id) => ({ objectID: id, })), ); return; } // Otherwise, silently check if this URL // already has an HN discussion. const stories = await findHN(location.href); if (stories.length) { console.log( "Found HN discussions:", stories.map((s) => s.objectID), ); if (isMobile()) { await createCollapsedButton(stories); } else { await openSidebar( stories.map((story) => ({ objectID: story.objectID, })), ); } } } init().catch(console.error); })();