From 6325f0ff32ad2eb93b4a7ebe76fd79ab41f932f3 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Thu, 27 Aug 2026 11:45:40 -0300 Subject: [PATCH] Add Reddit with its comment threads, and show quoted posts whole Two changes. They share the `Segment` model, which is why they arrive together. ## Reddit A new adapter under /reddit, plus the thread beneath the post -- on Reddit the conversation is usually the reason the link was shared, so a viewer that showed only the post would be showing the wrong half. The `.json` twin of a post URL is the post and the whole first page of comments in one response, far better than anything the page gives up, so it is the only layer that normally runs. Reddit refuses it to a browser it has never seen and answers with a JavaScript challenge, which any ordinary navigation solves by itself; the adapter navigates once and retries, and the cookie left behind serves every later post. Below that, `shreddit-comment` elements are read from the rendered page -- flat, each carrying its own `depth`, so `treeFromDepths` rebuilds the nesting. `Post.comments` is a tree rather than a flat list with depths, because folding a comment has to take everything under it along and nesting is what makes that free. Each comment renders as a `
`, so collapsing works with the stylesheet off and from the keyboard, and a collapsed one says how many replies it is hiding. "Collapse all" is the only part that needs the script, so it ships hidden and appears once the script has run. What sits behind a "load more" is not fetched -- that is a second page and often a third -- but it is counted and said out loud rather than quietly dropped. Four things the payloads got wrong on the first try, each now with a fixture: `fallback_url` is the video track alone whenever `has_audio` is true, so a post with sound has to use `hls_url` and only a silent one gets the proxied MP4; `scrubber_media_url` looks like a poster and is a second MP4 for the timeline thumbnails, while the still is in `preview.images`; a gallery's pictures live in `media_metadata` keyed and unordered, with their order only in `gallery_data`; and `replies` is the string "" rather than an object when there are none. Comment bodies are Markdown, rendered by a new render/markdown.ts that escapes first and then puts back only the constructs we chose to support -- never Reddit's own `body_html`, which would mean trusting markup a stranger caused to be generated. An app share link (/r//s/) is a plain 301, so one request told not to follow it is enough. The permalink it resolves to is what the copy button hands back, since an opaque share code is a tracking parameter by another name. ## Quoted posts Both X and Bluesky lifted the quoted post's media out and showed it as the quoter's own, dropping the quoted words and the quoted author entirely. A quote of a photo post therefore rendered as somebody else's picture under the wrong name with nothing to say so, and a quote that had a picture of its own dropped the quoted one instead -- the two could never both appear. Half the quote posts people share are someone answering a stranger and the other half are someone continuing a thought from an earlier post; neither reads with only one side of it on the page. `Segment.quoted` now carries the whole thing -- author, words, pictures, time and a link to it -- and renders as a post inside the post. Neither payload carries a usable address for it: X has no permalink and it is rebuilt from the handle and `id_str`, Bluesky has an `at://` URI nobody can open and it is rebuilt from the handle and the record key. On Bluesky the record sits at `embed.record` for a plain quote and at `embed.record.record` when the quoting post has media of its own, and a quote can also point at a feed, a list or a post since deleted, which arrive in the same slot under a different `$type` -- only `app.bsky.embed.record#viewRecord` is taken. Two things about X's text, both visible on any post and not only a quote. It arrives pre-escaped, so an ampersand someone typed was reaching the page as the literal `&`; it is decoded in the adapter, where the encoding comes from, leaving the escape-on-the-way-out rule alone. And every link is a `t.co`, which tells the reader nothing and routes them through X's click tracker to find out -- `entities.urls` carries the real address alongside, so it is put back. The shortlink X staples onto the end of a quote post is dropped rather than expanded, since the post it points at is already on the page; `display_text_range` is where that boundary is and it keeps a link the author put there deliberately. Its indices are UTF-16 units into the escaped text, so the slice happens before decoding and before expanding, and splitting to codepoints first overshoots past an emoji -- checked against a post carrying one. A quote is context, and context that fills the screen has stopped being context, so a segment carrying one gives up the window-filling cap on its own media. ## Along the way setupMedia only ever wired `document.querySelector('.media')`, the first rail on the page. That was already wrong for a Bluesky or Threads chain with media in more than one post, and a quoted carousel would have hit it too. It now runs per rail. ## Verified 108 tests, typecheck and build clean. Resolved end to end against the live platforms: Reddit self, gallery, video, link and share-link posts; X quotes with no media, with media on the quoted side, and with media on both; Bluesky quotes in both embed shapes, including a ten-post chain where every post quotes a different account and all nine quotes come back under the right name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu --- CLAUDE.md | 50 +- README.md | 48 +- public/app.css | 226 ++++++++ public/app.js | 40 +- src/platforms/bluesky.ts | 63 ++- src/platforms/index.ts | 3 +- src/platforms/reddit.ts | 466 +++++++++++++++++ src/platforms/url.ts | 1 + src/platforms/x.ts | 136 ++++- src/render/layout.ts | 1 + src/render/markdown.ts | 167 ++++++ src/render/post.ts | 141 ++++- src/render/text.ts | 1 + src/types.ts | 63 ++- test/bluesky.test.ts | 53 +- test/fixtures/bluesky/quote-with-media.json | 35 ++ test/fixtures/bluesky/quote.json | 41 ++ test/fixtures/reddit/gallery.json | 541 ++++++++++++++++++++ test/fixtures/reddit/link.json | 102 ++++ test/fixtures/reddit/self.json | 351 +++++++++++++ test/fixtures/reddit/video.json | 451 ++++++++++++++++ test/fixtures/x/quote-both.json | 128 +++++ test/fixtures/x/quote-photo.json | 70 +++ test/fixtures/x/quote-text.json | 28 + test/markdown.test.ts | 66 +++ test/reddit.test.ts | 170 ++++++ test/render.test.ts | 129 +++++ test/x.test.ts | 121 ++++- 28 files changed, 3623 insertions(+), 69 deletions(-) create mode 100644 src/platforms/reddit.ts create mode 100644 src/render/markdown.ts create mode 100644 test/fixtures/bluesky/quote-with-media.json create mode 100644 test/fixtures/bluesky/quote.json create mode 100644 test/fixtures/reddit/gallery.json create mode 100644 test/fixtures/reddit/link.json create mode 100644 test/fixtures/reddit/self.json create mode 100644 test/fixtures/reddit/video.json create mode 100644 test/fixtures/x/quote-both.json create mode 100644 test/fixtures/x/quote-photo.json create mode 100644 test/fixtures/x/quote-text.json create mode 100644 test/markdown.test.ts create mode 100644 test/reddit.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index ff4ea07..3603d7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,9 @@ # antisocial — working notes A self-hosted page that shows a social post without the app. StopTheMadness rewrites -links to X, Threads, Instagram, TikTok and Bluesky into `//`; -this resolves the post by driving a real headless Chromium and renders the media, the -text, a platform badge and a copy-the-original button. +links to X, Threads, Instagram, TikTok, Bluesky and Reddit into +`//`; this resolves the post by driving a real headless Chromium +and renders the media, the text, a platform badge and a copy-the-original button. Built for one person on a private network, with no authentication. `README.md` has the rewrite rules and the user-facing description. This file is the working context. @@ -35,6 +35,7 @@ different fixes: | Right media, wrong count | The structured payload was missed and it fell through to the DOM, which only shows the first carousel item. | | Poster image where a video belongs | Same fall-through, plus the `