You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>] became substack.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.js parses 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. Its substack.com host 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.js gains fetchChatChannel / fetchChannelPosts for 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. pickLiveliestPost ranks by newest reply, not newest post β ranking by created_at would 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=asc is accepted and ignored, after= is accepted and ignored, and only before=<ISO> actually pages. That's documented in lib/api.js next 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 missing publication_id used 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 reads classList synchronously 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.