Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5d1ac51
Port to Manifest V3 and rebuild the stylesheet
skunkworker Aug 13, 2026
b94504d
Add the proposal set, and keep it out of the packaged zips
skunkworker Aug 13, 2026
0c8ef5a
Add a palette option: newsprint, ember, slate, letterpress
skunkworker Aug 13, 2026
b13fcc4
Update the palette write-up to match what shipped
skunkworker Aug 13, 2026
a054a28
Simplify the palette work after review
skunkworker Aug 13, 2026
c6a283e
Fix the login-page throw, and keep fg-subtle off actual words
skunkworker Aug 13, 2026
43e6077
Add browser-driven tests for the parts that kept breaking
skunkworker Aug 13, 2026
53bba9c
Add degenerate-body regression tests
skunkworker Aug 13, 2026
065b4bf
Bring the plan's status up to date
skunkworker Aug 13, 2026
aa0f7d2
Put the settings that were never settings behind the gear
skunkworker Aug 14, 2026
77ae353
Type-check the JavaScript, and fix what it found
skunkworker Aug 14, 2026
bb55c72
Fix what the type checker found
skunkworker Aug 14, 2026
60dce79
Remove the inline reply, which had not run in years
skunkworker Aug 18, 2026
8bacad8
Fold three duplications into the helpers they were asking for
skunkworker Aug 18, 2026
9dff4fa
Propose a Safari for iOS port
skunkworker Aug 18, 2026
5194d1a
Put the settings panel behind four tabs
skunkworker Aug 18, 2026
06fff85
Give each browser its own manifest at package time
skunkworker Aug 18, 2026
acdf5b7
Say so when a setting saves but cannot show
skunkworker Aug 18, 2026
9625680
Scroll the panes, not the panel
skunkworker Aug 18, 2026
909f6d6
Bring the README up to what the extension is now
skunkworker Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ A Hacker News extension for Firefox and Chrome which changes lots of things.

Features
--------
* Completely new style
* Easy access to all pages
* Completely new style, with light and dark themes and five colour palettes
* A settings panel behind the gear in the header
* Easy access to all pages, and you pick which ones are header tabs
* Enhanced comment threads
* Collapsible comments
* Inline commenting
* Link to parent
* Display all comments on paginated threads
* Highlight the original poster
Expand All @@ -25,16 +25,55 @@ Features
* Graphs on polls
* Clickable links in self posts and on users profile pages
* New smooth and scalable up & down vote arrows
* Keyboard controls on index pages:
* Keyboard controls on index pages, which can be turned off:
* j - Next item
* k - Previous item
* o - Open story
* l - Open story in a new tab
* p - View comments
* c - View comments in a new tab
* b - Open both the comments and the story in new tabs
* h - Open the settings panel
* Tag users

Settings
--------
The gear at the right of the header, in four tabs:

* **Look** - theme (auto, light, dark), view (comfortable, compact, flow), and
five palettes
* **Reading** - new-comment highlighting, hckrnews.com unread counts
* **Sections** - which of Hacker News' fourteen section pages are header tabs
and which stay under "more"
* **Storage** - how much the extension is holding, and a way to clear collapsed
comment state, which is the one store that never shrinks

Everything is stored locally in `chrome.storage.local`; nothing is sent
anywhere. The Look settings apply immediately, and to any other Hacker News tab
you have open. The rest are read while a page loads and turned into markup, so
changing one offers a reload rather than pretending it took effect.

Building and loading
--------------------
Manifest V3, and the repo is the extension - there is no build step.

* **Chrome** - `chrome://extensions`, turn on Developer mode, Load unpacked, and
pick this directory.
* **Firefox** - `./zip.sh`, then `about:debugging` -> Load Temporary Add-on ->
`../HNES-firefox.zip`.

The two cannot share a background key: Chrome has no event page and Firefox has
no service worker, and each warns about the other's key. So the manifest in the
tree is Chrome-shaped and `zip.sh` writes the Firefox one into that package.
`zip.sh` builds both store packages.

Tests
-----
Seven harnesses drive a real browser with the extension loaded, because almost
everything here is rewriting a page it does not control. `cd test && npm install`,
then see [test/README.md](test/README.md) for what each one covers and which
need the network.

Firefox AMO link
----------------
https://addons.mozilla.org/en-US/firefox/addon/hnes/
Expand All @@ -45,7 +84,6 @@ https://chrome.google.com/webstore/detail/bappiabcodbpphnojdiaddhnilfnjmpm

TODO
----
* Options page
* Put search in a better place + ajax auto-complete
* Do something with un-threaded comment lists (e.g. best comments)
* Make profiles prettier
Expand Down
172 changes: 139 additions & 33 deletions background.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,147 @@
// Add event listeners
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
console.log('REQUEST', request.method, request)
if (request.method == "getAllLocalStorage") {
sendResponse({data: localStorage});
}
else if (request.method == "getLocalStorage") {
sendResponse({data: localStorage[request.key]});
}
else if (request.method == "setLocalStorage") {
localStorage[request.key] = request.value;
sendResponse({});
/*
* HNES background worker.
*
* Chrome runs this as an MV3 service worker, Firefox as an event page. It has no
* message handlers any more: content scripts reach chrome.storage.local directly.
* What is left is one-time maintenance — rescuing the MV2 localStorage store and
* sweeping expired entries.
*/

const MIGRATION_FLAG = 'hnesMigratedFromLocalStorage';
const OFFSCREEN_URL = 'offscreen.html';

chrome.runtime.onInstalled.addListener(() => {
migrateLegacyStorage()
.then(() => expireOldEntries())
.catch(e => console.error('HNES: maintenance failed', e));
});

chrome.runtime.onStartup.addListener(() => {
expireOldEntries().catch(e => console.error('HNES: expiry sweep failed', e));
});

/*
* Everything the extension persisted before v2 — user tags, upvote counts, per-thread
* read positions — lived in the MV2 background page's localStorage, which a service
* worker cannot see. The data itself survives the upgrade because the extension origin
* is unchanged, so a document running on that origin can still read and forward it.
*
* On failure the flag is deliberately left unset: the localStorage copy is still on
* disk, so a later run gets another attempt.
*/
async function migrateLegacyStorage() {
const flag = await chrome.storage.local.get(MIGRATION_FLAG);
if (flag[MIGRATION_FLAG]) return;

let legacy;
try {
legacy = await readLegacyStorage();
} catch (e) {
console.error('HNES: could not read legacy localStorage, will retry later', e);
return;
}
else if (request.method == "getUserData") {
var data = getUserData(request.usernames);
sendResponse({ data: data });

// Only the legacy keys need checking, so this stays bounded even though the
// collapse-state store can grow to tens of thousands of entries.
const existing = await chrome.storage.local.get(Object.keys(legacy));
const toWrite = {};

for (const key of Object.keys(legacy)) {
// Never clobber anything the v2 build already wrote (e.g. comment collapse state).
if (key in existing) continue;
toWrite[key] = normalizeLegacyValue(legacy[key]);
}
else {
sendResponse({});

console.log(`HNES: migrated ${Object.keys(toWrite).length} entries out of localStorage`);
toWrite[MIGRATION_FLAG] = true;
await chrome.storage.local.set(toWrite);
}

/*
* Upvote counts were originally stored as a bare number ("3") and later as
* '{"votes":3}'. The lazy converter in hn.js checked `typeof value === "number"`, which
* localStorage could never satisfy — it always hands back strings — so legacy entries
* fell through to JSON.parse and became a bare number, whose .votes is undefined. That
* left those users' scores invisible and stuck. Convert them once, here.
*
* Keyed on value shape rather than key shape: a bare run of digits is only ever a
* legacy vote count. Everything else is a JSON object (thread read-state), a URL, or
* one of the "true"/"false" flags, so none of them need naming individually — which
* also means a flag added later cannot be silently reshaped.
*/
function normalizeLegacyValue(value) {
return /^\d+$/.test(value) ? JSON.stringify({ votes: Number(value) }) : value;
}

async function readLegacyStorage() {
// Firefox's MV3 event page still has localStorage; only Chrome needs the detour.
if (typeof localStorage !== 'undefined') return snapshotLocalStorage(localStorage);
return readLegacyStorageViaOffscreen();
}

/*
* Walks by index rather than spreading the Storage object: a spread reads own
* enumerable properties, which misbehaves for keys that collide with Storage's
* own members (a stored key literally named "length" or "getItem"). offscreen.js
* inlines the same walk — it runs in its own document and cannot import.
*/
function snapshotLocalStorage(store) {
const out = {};
for (let i = 0; i < store.length; i++) {
const key = store.key(i);
out[key] = store.getItem(key);
}
});
return out;
}

async function readLegacyStorageViaOffscreen() {
if (!chrome.offscreen) throw new Error('offscreen API unavailable');

await chrome.offscreen.createDocument({
url: OFFSCREEN_URL,
reasons: ['LOCAL_STORAGE'],
justification: 'Read the Manifest V2 background page localStorage so saved user tags, upvote counts and thread read positions survive the upgrade.'
});

function getUserData(usernames) {
var results = {};
for (var i = 0; i < usernames.length; i++) {
var key = usernames[i],
value = localStorage[key];
results[key] = value;
try {
return await chrome.runtime.sendMessage({
target: 'offscreen',
method: 'readLegacyStorage'
});
} finally {
await chrome.offscreen.closeDocument();
}
return results;
}

//expire old entries
(function() {
for (i=0; i<localStorage.length; i++) {
var info = JSON.parse(localStorage[localStorage.key(i)]);
var now = new Date().getTime();
if (now > info.expire)
localStorage.removeItem(localStorage.key(i));
/*
* Thread read-state entries carry a five-day `expire` stamp. The MV2 sweep was written
* as `(function(){...});` with no trailing call, so it never ran once and stale entries
* have accumulated for the life of the extension.
*
* Comment collapse state is stored as an object rather than a string and has no expiry,
* so it is skipped here — it still grows without bound.
*/
async function expireOldEntries(snapshot) {
const all = snapshot || await chrome.storage.local.get(null);
const now = Date.now();
const stale = [];

for (const key of Object.keys(all)) {
const value = all[key];
if (typeof value !== 'string') continue;

let info;
try {
info = JSON.parse(value);
} catch (e) {
continue;
}

if (info && typeof info.expire === 'number' && now > info.expire) stale.push(key);
}
});

if (stale.length) {
await chrome.storage.local.remove(stale);
console.log(`HNES: expired ${stale.length} stale entries`);
}
}
34 changes: 34 additions & 0 deletions js/boot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Runs at document_start, before HN's markup is parsed.
*
* Two jobs, both of which have to happen before first paint:
*
* - Mark the document as pending so the stylesheet can hide the raw HN markup
* while hn.js rewrites it. Putting the flag here rather than in CSS means a
* page where the content script never runs is never hidden at all, and the
* stylesheet's failsafe animation reveals the page even if hn.js throws.
* - Start the settings read and apply the painting ones — theme, view density
* and palette. The read is async, so it can land after paint; that is
* harmless because the body is still hidden, and an unset value falls
* through to the stylesheet's own default — prefers-color-scheme for theme,
* comfortable for density, classic for palette.
*
* Issuing that read here rather than in hn.js is also what keeps it off the
* critical path: it is in flight while HN's markup is parsing, so hn.js's
* HNESModes.ready() at document_end almost always resolves without waiting.
*
* The settings themselves live in modes.js, which the manifest injects just
* ahead of this file; hn.js and its settings panel read the same one.
*/
(function () {
var root = document.documentElement;
if (!root) return;

root.classList.add('hnes-pending');

var MODES = globalThis.HNESModes;
if (!MODES) return;

MODES.load(function (items) { MODES.applyAll(root, items); });
MODES.watch(root);
})();
Loading