cc4f094fb8fc3a81f654cdbf98b5308b3941aeb9
6
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
abf8ec317c
|
Let the "Open on" button choose a browser
CI / Typecheck, test, build (pull_request) Successful in 28s
The StopTheMadness rules are indiscriminate, which is the point, but they catch the link on the way back out as well: in the browser they are installed in, "Open on <platform>" redirects straight back here. The one button meant to reach the app is the one that cannot. Handing the address to a different browser is the way past it, and the only way to do that from a page is that browser's own URL scheme. `/` gets a picker for which one; the choice lives in that browser's localStorage, because which browsers are installed is a fact about the device and the phone's answer is not the Mac's. The scheme table is its own module so a test can pin it -- every browser spells it differently, and Edge differs between macOS and iOS. Firefox has no scheme on macOS, so it is only offered on iOS, and an unknown or schemeless choice keeps the plain link rather than producing a dead one. The markup still carries the plain https address and the script swaps it afterwards, so nothing changes without JavaScript. Once a browser is chosen the button says which, since a scheme for a browser that is not installed opens nothing and the tap would otherwise be silent. Closes #7 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KF5YF3iZVKbezwALap8LYd |
||
|
|
0db18547c8
|
Show the pictures inside Reddit comments
CI / Typecheck, test, build (pull_request) Successful in 11s
A comment that was a picture rendered as either a link or, for a Giphy, the literal text ``. On r/aww that is most of the thread. Reddit writes an inline image as a token rather than an address, in three shapes: `` for a Giphy, `` for a subreddit emote, and `` for an image uploaded straight to the comment. The useful part is that all three tokens are keys in that same comment's own `media_metadata`, so this is one lookup and not three special cases. Nothing in the adapter has to know what Giphy is. The fourth shape is someone pasting the address of a picture, which on Reddit is how most images in comments actually arrive -- 117 of them against 21 Giphys in the sample I scanned. Those are shown as pictures too, decided by the file extension. A link that is not to an image stays a link. Animated ones take `s.gif` over `s.mp4` even though the MP4 is several times smaller: a GIF moves on its own in an `<img>`, and an MP4 would need a player element with autoplay, loop and muted set, for something the size of a postage stamp. Everything goes through the `/m/` proxy, like all other media. Without that a comment thread would have the reader's browser fetch dozens of files straight from Reddit, which is the one thing this whole app exists to avoid. Placing the image is the renderer's job, not the Markdown parser's, because the proxy is a render-time concern and markdown.ts knows nothing about it -- so it takes an optional `ImageRenderer` and, without one, an image stays a link exactly as before. The parser also learned `![...]` proper: the link rule was matching from the `[` and stranding the `!` as text. Verified on r/aww/comments/171dxph, which carries one Giphy and 77 pasted images: 35 render on the first page, all 35 load, all 35 through the proxy, no upstream address reaches the page, no token is left unresolved, and nothing overflows the column or scrolls the page sideways. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu |
||
|
|
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 `&`; 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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
b5f9483615
|
Initial commit: read social posts back without the app
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 |