Commit Graph
9 Commits
Author SHA1 Message Date
thatguygriffandClaude Opus 5 6325f0ff32 Add Reddit with its comment threads, and show quoted posts whole
CI / Typecheck, test, build (pull_request) Successful in 9s
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 `<details open>`, 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/<sub>/s/<code>) 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 `&amp;`; 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
2026-08-27 11:45:40 -03:00
thatguygriff 361cdbd966 Merge pull request 'Keep a post inside the window, and stop letterboxing it' (#2) from media-fits-viewport into main
CI / Typecheck, test, build (push) Successful in 14s
Publish / Build and push (push) Successful in 2m37s
Reviewed-on: #2
2026-08-26 20:26:41 +00:00
thatguygriffandClaude Opus 5 df50fa7e93 Keep a post inside the window, and stop letterboxing it
CI / Typecheck, test, build (pull_request) Successful in 34s
On a desktop a portrait video came with black bars all the way around and sat
partly below the fold, so you had to scroll to find the thing you had opened.

The bars were the figure, not the video. It carried the media's aspect ratio,
so at the full width of the column a 720x1280 reel forced a box 1204px tall
inside a 982px window, and the video -- capped at 78vh -- floated in the middle
of it with 438px of black above and below and 247px either side. The ratio now
goes on the media itself, which sizes to its own proportions instead of being
stretched to the column, and the background is the card's rather than black, so
what space is left reads as page instead of as a border.

The height cap is the window less the chrome around it. Deriving that from the
layout is the obvious approach and does not work: the card is sized by its
contents, so every percentage height inside resolves to auto and clamps
nothing. Measured across the posts this serves, the header, caption and footer
come to 270-350px, so the cap subtracts 350 and the whole post fits. Smaller
than it might be on a short window, but a video can be full-screened and a post
you have to hunt for cannot.

Threads keep the looser cap and scroll, which a column of several posts was
always going to do.

Grid thumbnails were quietly broken and the measuring turned it up: the cell
takes its height from an aspect ratio, which a percentage height will not
resolve against, and `aspect-ratio: auto` asks for the image's own ratio rather
than none, so a portrait thumbnail rendered at twice the height of its cell and
spilled out. They fill their cell absolutely now.

Verified at 1512x982, 1280x800, 1440x700 and 390x844: no letterboxing anywhere,
and the media sits above the fold on every one.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
2026-08-26 17:24:22 -03:00
thatguygriff e49f1ce88b Merge pull request 'Show the author's own chain on Bluesky and Threads' (#1) from threads-and-chains into main
CI / Typecheck, test, build (push) Successful in 9s
Publish / Build and push (push) Successful in 3m20s
Reviewed-on: #1
2026-08-26 20:17:22 +00:00
thatguygriffandClaude Opus 5 60a9468875 Show the author's own chain on Bluesky and Threads
CI / Typecheck, test, build (pull_request) Successful in 46s
People write in chains on both, and a link into one arrives pointing at a
single post out of several. Showing only that post loses the thing that was
being said. Other people's replies are a different matter: they are a
conversation rather than the thing that was shared, and on a busy post there
are hundreds of them.

A Post is now a list of Segments instead of one body. Most platforms produce
exactly one and say so through oneSegment(); the two that thread produce the
whole chain, with isAnchor marking the post that was actually linked, which
need not be the first.

Bluesky walks parent upward and the author's own replies downward, stopping at
the first post by anyone else. That needs depth and parentHeight on
getPostThread, which drags the entire reply tree along -- a few hundred KB on a
popular post -- because there is no way to ask the API for one author's branch.

Threads is harder to read. The page ships the linked post, the author's
follow-ups, other people's replies and a pile of unrelated recommendations, all
as flat thread_items containers with no nesting to go on. What separates a
follow-up from a stranger's reply is that a follow-up is the author replying to
themselves; a reply from someone else carries the same reply_to_author with a
different name on it. The first post of a chain replies to nothing at all, so
it is reachable only by walking backwards from the post that answers it -- a
test caught that, when linking the second post of a thread returned just the
one post.

Fixtures for both are real captures. The Bluesky one keeps two of every level's
outside replies rather than pruning them away, because a filter is only worth
testing against the thing it is supposed to exclude.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
2026-08-26 17:16:02 -03:00
thatguygriff 2a8f71adc7 Update StopTheMadness regex for copy/pasting properly
CI / Typecheck, test, build (push) Successful in 51s
Publish / Build and push (push) Successful in 2m8s
2026-08-26 13:50:49 -03:00
thatguygriffandClaude Opus 5 deeedbcc85 Match version tags with globs, not regex
CI / Typecheck, test, build (push) Successful in 48s
Publish / Build and push (push) Successful in 2m41s
Workflow tag filters are glob patterns, so the `[0-9]+` in the previous
version had `+` read as a literal plus and would never have matched 1.0.0 —
the release would have pushed a tag and quietly built nothing.

`[0-9]*.[0-9]*.[0-9]*` uses only basic glob, matches 1.2.3 and 1.2.3-rc1
alike, and still ignores tags that are not versions. The prerelease patterns
collapse into it, since the trailing wildcard already absorbs a suffix.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
2026-08-26 12:10:53 -03:00
thatguygriffandClaude Opus 5 fb12eb6c9f Slim the image, publish on version tags, drop deployment specifics
Publish / Build and push (push) Failing after 32s
CI / Typecheck, test, build (push) Successful in 34s
The first publish failed partway through the push with 413 Payload Too Large:
one layer was bigger than the proxy in front of the registry would accept.

Three changes, only one of which is that fix.

Keep deployment out of a public repo. The registry, image name and credentials
now come from repository variables and secrets rather than being written down
here, and the docs describe how to run the thing rather than where one
particular instance runs. PUBLIC_ORIGIN defaults to localhost. The 413 is a
proxy limit, so the fix is pointing REGISTRY at a host the runner reaches
directly; the workflow explains itself if that host is plain HTTP and the
builder's daemon has not been told to allow it.

Publish on version tags. A tag like 1.2.3 publishes :1.2.3, :1.2, :1 and
:latest; a prerelease publishes only its exact version and leaves :latest
alone. Pushes to main publish :main and :sha-<short> and no longer move
:latest, so what is deployed moves when a release says so.

Shrink the image from over 1.2GB to 353MB. The Playwright base image carries
Firefox and WebKit, which this never launches. Installing just the browser it
does launch onto a slim Node base drops two thirds of the weight, which is
worth having on a Raspberry Pi even though it does not get any single layer
under a proxy limit.

That last change surfaced something worth naming: a headless launch resolves to
Playwright's headless shell, not the full browser, so that is what every test
so far has actually been running. The image now installs exactly that binary
and pool.ts names the channel, so the two cannot drift apart.

Verified in the container: Bluesky, Instagram, X and Threads all resolve
identically on the slim image.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
2026-08-26 12:05:02 -03:00
thatguygriffandClaude Opus 5 b5f9483615 Initial commit: read social posts back without the app
CI / Typecheck, test, build (push) Successful in 28s
Publish / Build and push (push) Failing after 2m24s
antisocial is the other half of a StopTheMadness redirect rule. Links to X,
Threads, Instagram, TikTok and Bluesky get rewritten to /<prefix>/<original
path>, and this resolves the post and shows the media and the words, with a
badge saying where it came from and a button to copy the original URL.

Every request drives a real headless Chromium, logged out, from a residential
IP. One code path, and it survives markup changes better than parsing from the
outside would. Extraction is layered, most structured first: the platform's own
API response caught in flight, then an inline payload, then the rendered DOM,
then Open Graph tags.

Media is never linked straight at a CDN. Instagram and TikTok reject requests
without a matching Referer and cookies, and proxying keeps the viewer's browser
from talking to the platform at all. Range is forwarded so the native video
scrubber can seek. HLS is the exception, since proxying it would mean rewriting
playlists.

TikTok sometimes answers with a slider puzzle. Rather than reporting that as a
failure, the page is parked and the viewer is handed the puzzle: screenshots
stream out, pointer events are replayed back. Solving it leaves the cookie in
the shared browser context, so the retry is an ordinary request.

A failed resolve is never a blank error page. The card carries the platform, the
original URL and the copy button, so a broken adapter still leaves the link one
tap away.

Verified end to end against real shared links on all five platforms, in the
container, including multi-image carousels, reels, TikTok short links and photo
posts. 49 tests run the adapters against captured payloads with no network.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
2026-08-26 11:32:25 -03:00