diff --git a/README.md b/README.md index 717738c..696c5d9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -25,7 +25,7 @@ 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 @@ -33,8 +33,47 @@ Features * 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/ @@ -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 diff --git a/background.js b/background.js index be28219..9e4febd 100644 --- a/background.js +++ b/background.js @@ -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 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`); + } +} diff --git a/js/boot.js b/js/boot.js new file mode 100644 index 0000000..d5f3637 --- /dev/null +++ b/js/boot.js @@ -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); +})(); diff --git a/js/hn.js b/js/hn.js index e6f1472..ea9e835 100644 --- a/js/hn.js +++ b/js/hn.js @@ -15,127 +15,21 @@ * Under MIT license, see LICENSE */ -var InlineReply = { - init: function() { - $('a[href^="reply?"]').click(function(e) { - if (HN.isLoggedIn()) { - e.preventDefault(); - } - else { - return; - } - - //make sure there's no stray underlining between Reply and Cancel - $(this).addClass('underlined'); - $(this).parent('u').replaceWith($(this)); - - /*remove the 'reply' link without actually hide()ing it because it - doesn't work that way with collapsible comments*/ - $(this).css('display', 'none'); - - domain = window.location.origin; - link = domain + '/' + $(this).attr('href'); - - if ($(this).next().hasClass('reply_form')) { - $(this).next().show(); - } - else { - //add buttons and box - $(this).after( - '
\ -