Repository navigation
Releases
v0.11.0 — 🧵 Thread switcher rail
Added — 🧵 Thread switcher rail
- A channel's chat is many threads, not one. BetterSSC previously rendered only the single liveliest thread in a Substack chat channel. A new left-hand rail lists the channel's other threads (a body snippet + reply count each), liveliest first — click one to switch. Switching navigates the page (a full reload with a new
?post=<uuid>) rather than swapping state in place: the app's per-thread state is ~15 fields wide (message store, scroll position, search, focus filter and its memo, WebSocket status, background-prefetch flags, …), and this project has been bitten before by an incomplete reset across exactly that kind of transition — a full navigate reuses the same boot path every other entry into the app already goes through, at the cost of a page reload instead of an instant in-app transition. - Hide empty threads, on by default. Most of a channel's posts are broadcast links with zero replies; the toggle in the rail header declutters them, but never hides the thread you're currently viewing regardless of its own reply count.
- Collapsible rail. A header icon (clickable in both states — it's what lets a collapsed rail reopen) shrinks it to a 32px strip; double-clicking the rail's edge divider does the same as a shortcut. Both the hide-empty and collapsed preferences persist across sessions.
- Pagination and ranking were captured live against a real busy channel, not inferred: the feed is descending by
created_at,order/afterare accepted and silently ignored, onlybefore=<ISO>actually pages. The "liveliest thread" ranking (newest reply, not newest post — the newest post is usually an empty broadcast) is shared between the boot-time picker and the rail's own ordering through one extracted scoring function, so a future Substack field rename can't be fixed in one place and drift in the other. A redacted real captured response is committed as a test fixture and asserted against directly. - 18 new tests (row formatting, ordering, the hide-empty filter's active-thread carve-out, the empty-filter fallback). 686/686 passing.
- Two independent code reviews (0 Critical across both; 5 should-fix + 4 should-fix, all folded in) caught, among others: an unguarded fetch for the rail's data that could hard-fail the ENTIRE chat load over a decorative feature (now soft-fails); a mobile CSS bug where the rail's grid column wasn't fully collapsed on narrow windows, verified live in a browser tab (an un-fixed render would have squeezed the whole chat stream into an 86px sliver); a stale comment intersecting position:relative with a still-scrolling container that would have let the collapse divider scroll away with a long thread list and leaked 3px of spurious horizontal scroll (restructured before ship, re-verified live); and keyboard focus being destroyed on every toggle click (now preserved across the rail's re-render).
- Still open: posting a new top-level message to a channel (starting a thread, vs. replying inside one) is not wired — that write path hasn't been captured, and per this project's rule a protocol gets decoded before it gets implemented. Replying inside a thread, which is what this client actually does, is unaffected.
v0.10.1 — 🔌 fix: follow Substack's chat-channel migration
Fixed — 🔌 Substack's chat re-architecture broke the extension entirely
- BetterSSC stopped opening any chat. Around 2026-09-06 Substack moved publication chat off the "publication with a flat list of posts" model onto a persistent channel model, and changed the URLs to match:
substack.com/chat/<pubId>[/post/<uuid>]becamesubstack.com/chat/group/<channelUuid>[/post/<uuid>]. Substack 301-redirects the old forms, so every chat tab silently moved to a URL the extension's parser — which only matched/chat/<digits>— could not read. The toolbar button then opened the app with no publication and no post, and it fell straight through to the landing screen. - The fix is URL-level, not a rewrite. Probing the live API from a logged-in tab confirmed every endpoint BetterSSC already depends on is intact and unchanged in shape — including
/community/posts/<uuid>/comments, which still serves the replies inside a channel post. So nothing downstream of picking a post needed migrating. - New
lib/chat-url.jsparses both URL generations (the legacy form still arrives from bookmarks and history) and builds the deep link back into Substack, now preferring the channel form so "open in Substack" stops relying on a redirect. Itssubstack.comhost check is kept and tested adversarially — the parser runs on the URL of whatever tab the toolbar button was clicked from, and its output feeds API paths. - A channel id alone is now enough to boot:
lib/api.jsgainsfetchChatChannel/fetchChannelPostsfor the two new/api/v1/chat/channels/…endpoints, and the app resolves the publication id from the channel record and a default post from the channel feed before anything reads either. - Which post it opens matters. The newest post in a channel is usually a broadcast link with zero replies; the live conversation is an older thread.
pickLiveliestPostranks by newest reply, not newest post — ranking bycreated_atwould have opened an empty stream, which reads as "BetterSSC loaded nothing", the exact symptom being fixed. - Endpoint semantics were captured live, not inferred (the lesson from the v0.1 WebSocket chase): the channel post feed is descending by
created_at,order=ascis accepted and ignored,after=is accepted and ignored, and onlybefore=<ISO>actually pages. That's documented inlib/api.jsnext to the wrappers. - 25 new tests (
test/chat-url.test.mjs), including 10 adversarial URL rejections and the zero-reply-broadcast-post case. 651 tests passing. - Independent code review (0 Critical, 5 should-fix, 5 nits — all folded in before commit) caught, among others: the resolution being async meant the composer was typeable for ~1-3s with both ids still null, so the resolve now runs first thing in
init(), before any listener is bound; a channel record missingpublication_idused to return normally and surface much later as "Publication null" and an opaque upload 404, and now throws; and the picker's timestamp field names were pinned only by hand-built fixtures, so a redacted real captured response is now committed (test/fixtures/channel-posts-page1.json) and asserted against — if Substack renames those fields the suite fails instead of silently opening an empty thread. The review also corrected the fix itself: deferring the app's un-hide until after resolution would have left the composer permanently unmounted, since its mount gate readsclassListsynchronously exactly once. - Still open: posting a new top-level message to a channel is not yet wired — that write path hasn't been captured, and this project's rule is to decode a protocol before implementing it rather than guess. Replying inside a thread, which is what this client actually does, is unaffected.
v0.10.0 — 🛡️ AI moderation before posting
v0.10.0 — AI moderation before posting
v0.7.1 — Link previews + X/Twitter tweet cards
🔗 Link previews (opt-in, local-only)
When someone posts a link in chat, BetterSSC shows a preview card (site · title · description · thumbnail) built from the page's Open Graph metadata, fetched in your browser session.
- Off by default; opt-in behind a host permission. Enable in Chat preferences — Chrome prompts for page-read access (declared as an optional permission, never requested at install). Turning it off relinquishes the permission.
- Local + per-viewer. Each client unfurls the links it sees; nothing is written back to Substack and nothing is shared with other readers.
- Cookieless + safe.
credentials: 'omit', response capped at 256KB, all page text rendered viatextContent(neverinnerHTML), images https-only withreferrerpolicy=no-referrer. Failed unfurls are negative-cached.
🐦 X / Twitter tweet cards (v0.7.1)
X/Twitter status links render like the original tweet: avatar + display name + @handle header with the X logo, the full tweet text, and the embedded media rendered large below. Avatar resolved via unavatar.io with a fallback to the X glyph; media-less tweets use their profile image as the avatar.
- Fixed: card text is no longer artificially underlined on hover.
515 tests passing. See CHANGELOG for full detail.
v0.6.0 — Rolling trending ticker bar
📈 Rolling ticker bar (CNBC/Bloomberg style)
A live TRENDING strip under the chat header that scrolls right→left, showing what the chat is talking about right now — pulled live from the messages, no setup.
- Three kinds of chips — trending stock/crypto tickers, @mentioned people, and topic keywords (1–2 words each).
- Click a chip → instant chat search (tickers search the bare symbol, people
@name, topics the word). - Live prices on ticker chips (green ▲ / red ▼ % change), pulled from Yahoo Finance via the background service worker — no API key, no CORS. Crypto maps to
<SYM>-USD. Unpriceable symbols just show the symbol. - Hover eases the strip to a halt so the chip under your cursor is clickable; leave and it glides back to speed. Respects
prefers-reduced-motion. - Selection — looks at the last 2 hours of chat, frequency-scored with newer mentions weighted higher (recent leads, but a heavily-discussed earlier mover still surfaces). Tickers via
$SYMBOL(known or unknown) or bare ALL-CAPS from the built-in list;$MSFT/MSFTdeduped. Topics gated to ≥2 distinct authors + stopword/length filter so filler doesn't leak. Tickers rank first, then people, then topics.
New https://query1/query2.finance.yahoo.com/* host permissions (price fetch). New pure lib/trending.js with 18 unit tests; full suite 462 green. Independent design + code review both ran.
Install/upgrade: reload the unpacked extension at chrome://extensions and accept the new Yahoo host permission.
See CHANGELOG.md for full details.