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