15 Commits
Author SHA1 Message Date
thatguygriff 05455a86da Merge pull request 'Ask iOS for the playback audio session on a page with a video' (#14) from video-audio-session-on-ios into main
CI / Typecheck, test, build (push) Successful in 46s
Publish / Build and push (push) Successful in 28s
Publish / Move the working version on (push) Successful in 4s
Reviewed-on: #14
2026-09-03 00:56:04 +00:00
thatguygriffandClaude Opus 5 4c78ce8a27 Ask iOS for the playback audio session on a page with a video
CI / Typecheck, test, build (pull_request) Successful in 12s
A TikTok post played perfectly and said nothing on an iPhone. The file was
not the problem: every rendition TikTok offers for it carries a full AAC
track, and the page as served measures loud audio in WebKit and Chromium
alike, straight through the /m/ proxy.

iOS is the problem. A video playing inline gets the "ambient" audio session,
which the Ring/Silent switch mutes; only going fullscreen gets the sound
back. Claiming "playback" says what is true of this page -- the sound is the
point, not decoration -- and the switch stops applying.

Declared only where there is a video, so an ordinary text post never claims
it, and the session activates when something plays rather than on load, so
it interrupts nothing on a page nobody presses play on.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01B3RUMiXa6eAW3okw9nB9NF
2026-09-02 21:06:12 -03:00
thatguygriff d9fa2f5768 Merge pull request 'Set the working version to 1.2.4' (#12) from release/bump-1.2.4 into main
CI / Typecheck, test, build (push) Successful in 17s
Publish / Build and push (push) Successful in 35s
Publish / Move the working version on (push) Skipped
Reviewed-on: #12
2026-08-31 02:44:07 +00:00
Release bot dfabfc18d6 Set the working version to 1.2.4 2026-08-31 02:42:23 +00:00
thatguygriff 3afdf6ffe2 Merge pull request 'Render giphy tokens, and give a video its shape before it plays' (#11) from giphy-tokens-and-video-posters into main
CI / Typecheck, test, build (push) Successful in 12s
Publish / Build and push (push) Successful in 1m51s
Publish / Move the working version on (push) Skipped
Reviewed-on: #11
2026-08-31 02:41:07 +00:00
thatguygriffandClaude Opus 5 5b4378a838 Give a video its shape before it has any data
CI / Typecheck, test, build (pull_request) Successful in 26s
A Reddit video sat in the wrong box until you pressed play, for two reasons
that looked like one.

The renderer put only `aspect-ratio` on the `<video>`. A video with no data
has a natural size of 300x150, and WebKit sizes a replaced element from that
rather than from the ratio, so a portrait video got a squat landscape box and
kept it until playback supplied real dimensions. Chromium stretch-fits
instead and gets it right, which is why this only showed on Safari. A
video's size before its data arrives is its poster's, so one with no poster
of its own now gets an empty SVG of the right shape as a stand-in: a data
URI, so it costs no request. Measured in both engines across portrait,
landscape, square and small.

And Reddit's videos with sound had no poster to be sized by, because
`fromRedditVideo` attached the still from `preview.images` to the MP4 branch
alone. The still belongs to the video, not to the format it is served in, so
every post with sound showed an empty box where a silent one showed a frame.
It now goes on both.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PLkmgp1fWbA4XbarxKdRKt
2026-08-30 23:37:59 -03:00
thatguygriffandClaude Opus 5 4669fe0b6a Resolve a giphy token with no metadata to look it up in
`![gif](giphy|zUW23b6FmzB5e)` is a token, not an address, and the only thing
that turned it into one was a lookup in the comment's own `media_metadata`.
Reddit ships plenty of comments carrying such a token and no `media_metadata`
at all, and with nothing to look it up in the token itself was what the
comment showed.

Giphy is the one of the three token kinds whose id means something off
Reddit, so that one can be resolved without the lookup. A variant name after
the id is dropped: Giphy does not serve every variant of every gif, but the
full one is always there.

The other two still resolve only through the metadata. An emote id and an
upload id name nothing outside Reddit, so with no entry for them there is
still nothing to point them at.

Closes #10

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PLkmgp1fWbA4XbarxKdRKt
2026-08-30 23:36:53 -03:00
thatguygriff ebfcc9ebf2 Merge pull request 'Set the working version to 1.2.3' (#9) from release/bump-1.2.3 into main
CI / Typecheck, test, build (push) Successful in 12s
Publish / Build and push (push) Successful in 1m50s
Publish / Move the working version on (push) Skipped
Reviewed-on: #9
2026-08-29 23:08:21 +00:00
Release bot 8fc7a83f8e Set the working version to 1.2.3 2026-08-29 22:56:36 +00:00
thatguygriff 91d4c2da50 Merge pull request 'Let the "Open on" button choose a browser' (#8) from choose-browser-for-open-on into main
CI / Typecheck, test, build (push) Successful in 18s
Publish / Build and push (push) Successful in 3m24s
Publish / Move the working version on (push) Successful in 23s
Reviewed-on: #8
2026-08-29 22:47:42 +00:00
thatguygriffandClaude Opus 5 899b6e38d8 No browser chosen, no "Open on" button
CI / Typecheck, test, build (pull_request) Successful in 26s
The plain address is the one thing that button must not offer -- followed
in the browser the rewrite rules are installed in, it comes straight back
here -- so there is nothing to show until there is a browser to hand it to.
The copy button and the selectable URL were always the part carrying the
weight; the open link now ships hidden and the script reveals it along with
the scheme.

That also settles what the button should say. It went back to "Open on
<platform>": the browser's name was there to explain a tap that went
nowhere, and a button that is not shown until it works needs no such
explanation.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KF5YF3iZVKbezwALap8LYd
2026-08-29 19:40:39 -03:00
thatguygriffandClaude Opus 5 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
2026-08-29 19:36:00 -03:00
thatguygriff 3ff7375320 Merge pull request 'Open the version bump as a pull request' (#6) from bump-version-by-pull-request into main
Publish / Build and push (push) Successful in 6s
Publish / Move the working version on (push) Skipped
CI / Typecheck, test, build (push) Successful in 13s
Reviewed-on: #6
2026-08-27 23:57:23 +00:00
thatguygriffandClaude Opus 5 43dc10e1dc Set the working version to 1.2.2
CI / Typecheck, test, build (pull_request) Successful in 14s
Publish / Build and push (pull_request) Successful in 20s
Publish / Move the working version on (pull_request) Skipped
1.2.1 shipped, so the tree was left on a version that is published and
immutable, which is the thing the bump job exists to prevent. It failed before
it got that far. Done by hand here, once: the job only fires on a tag, so no
re-run of it will do this retroactively.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XBT25ZDzm453A8XViSRqRB
2026-08-27 20:35:36 -03:00
thatguygriffandClaude Opus 5 b94c43a10c Open the version bump as a pull request
The bump job has never once worked. Its first step died in two seconds on
`set: Illegal option -o pipefail` -- it is the only job here that runs in a
container, and a container job is handed `sh -e {0}` rather than the bash the
runner gives its own jobs. Dash has no `pipefail`, and no `10#` either, so the
arithmetic on the line after would have gone the same way. It now asks for
bash, which node:22 carries.

That would have got the job as far as its last step, which pushed straight to
main. Nothing had ever exercised that, and it needs the token to be allowed
past whatever protects the branch -- hence the second token, VERSION_BUMP_TOKEN,
standing by for where it is not. unsupervised-scheduler has been bumping its
version on every release for a while by pushing a branch and opening a pull
request with the ordinary task token, so that is what this does now. The second
token is no longer needed, and neither is `[skip ci]`: main is never pushed, so
there is no build of the just-published image to suppress. The pull request
puts the changed package.json through a build before it lands.

scheduler builds the request body with jq because its CI image carries jq. This
one is node:22, where node is the thing that certainly is there.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XBT25ZDzm453A8XViSRqRB
2026-08-27 20:35:33 -03:00
17 changed files with 499 additions and 64 deletions
+56 -20
View File
@@ -157,19 +157,26 @@ jobs:
# it is a candidate for, so there is nothing yet to move past. # it is a candidate for, so there is nothing yet to move past.
if: github.ref_type == 'tag' && !contains(github.ref_name, '-') if: github.ref_type == 'tag' && !contains(github.ref_name, '-')
runs-on: ubuntu-latest runs-on: ubuntu-latest
# npm does the bump, so this one job wants node. A job in a container is
# given `sh -e {0}` as its shell rather than the bash the runner's own jobs
# get, and dash has neither `pipefail` nor the `10#` below — which failed
# the first line of the first step the one time this ran. node:22 is Debian
# and carries bash, so asking for it keeps these scripts the same as the
# ones in the job above.
container: container:
image: node:22 image: node:22
defaults:
run:
shell: bash
steps: steps:
# The tag names a commit in main's history, but the bump belongs on the # The tag names a commit in main's history, but the bump belongs on the
# branch, so this checks out main rather than the tag. # branch, so this checks out main rather than the tag. The full history
# because a shallow clone cannot reliably push a branch back.
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: main ref: main
# The task token can push only if the instance allows Actions to fetch-depth: 0
# write to the repository. Where it does not, set VERSION_BUMP_TOKEN token: ${{ secrets.GITHUB_TOKEN }}
# to a personal access token with write access and it is used
# instead.
token: ${{ secrets.VERSION_BUMP_TOKEN || secrets.GITEA_TOKEN }}
- name: Work out the next patch version - name: Work out the next patch version
id: next id: next
@@ -211,30 +218,59 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT" echo "changed=true" >> "$GITHUB_OUTPUT"
echo "package.json ${current} -> ${NEXT}" echo "package.json ${current} -> ${NEXT}"
- name: Commit it to main # The bump arrives as a pull request rather than as a commit straight to
# main. Pushing a branch asks nothing of the task token beyond ordinary
# write access, so nothing here depends on being allowed past whatever
# protects main; and the pull request puts the changed package.json
# through the build before it lands. Since main is never pushed, the
# `[skip ci]` that would otherwise be needed to stop this rebuilding the
# image just published is not.
- name: Open a pull request for it
if: steps.bump.outputs.changed == 'true' if: steps.bump.outputs.changed == 'true'
env: env:
NEXT: ${{ steps.next.outputs.next }} NEXT: ${{ steps.next.outputs.next }}
RELEASED: ${{ github.ref_name }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
branch="release/bump-${NEXT}"
# A name that is not a person, and a reserved address that can never # A name that is not a person, and a reserved address that can never
# resolve to one. Nothing here names the instance it runs on. # resolve to one. Nothing here names the instance it runs on.
git config user.name 'Release bot' git config user.name 'Release bot'
git config user.email '[email protected]' git config user.email '[email protected]'
git checkout -b "${branch}"
git add package.json package-lock.json git add package.json package-lock.json
# `[skip ci]` because this commit is a number and nothing else: git commit -m "Set the working version to ${NEXT}"
# without it the push to main starts another build of the very image git push origin "${branch}"
# that was just published.
git commit -m "Set the working version to ${NEXT} [skip ci]"
if ! git push origin HEAD:main; then # node rather than jq to build the request body: jq is not in this
echo >&2 # image, and node is the one thing that certainly is.
echo "Could not push the version bump to main. Either the Actions" >&2 payload="$(BRANCH="${branch}" node -e 'process.stdout.write(JSON.stringify({
echo "token has no write access to this repository, or main is" >&2 head: process.env.BRANCH,
echo "protected against direct pushes. Set VERSION_BUMP_TOKEN to a" >&2 base: "main",
echo "token that may push to main, or allow that token past the" >&2 title: `Set the working version to ${process.env.NEXT}`,
echo "branch protection." >&2 body: `${process.env.RELEASED} has shipped, so the tree was left on a version that is published and immutable. This moves it on to ${process.env.NEXT}, which is deliberately not a version that exists.`,
exit 1 }))')"
fi
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
response="$(mktemp)"
code="$(curl -sS -o "${response}" -w '%{http_code}' \
-X POST "${api}/pulls" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "${payload}")"
case "${code}" in
201) echo "Opened ${branch} against main." ;;
# A release re-run that got this far: the branch and its pull
# request are already there, which is the state we wanted anyway.
409) echo "A pull request for ${branch} is already open." ;;
*)
echo "Could not open the pull request (HTTP ${code}):" >&2
cat "${response}" >&2
exit 1
;;
esac
+42 -17
View File
@@ -96,6 +96,15 @@ Things worth knowing before editing:
`src/media/registry.ts` and served from `/m/<id>` with the `Referer`/`Cookie` the CDN `src/media/registry.ts` and served from `/m/<id>` with the `Referer`/`Cookie` the CDN
demands. `Range` is forwarded — without it the native video scrubber cannot seek. demands. `Range` is forwarded — without it the native video scrubber cannot seek.
The exception is HLS (`direct: true`), because proxying would mean rewriting playlists. The exception is HLS (`direct: true`), because proxying would mean rewriting playlists.
- **A video is sized by its poster**, not by the ratio the renderer puts on it. A
`<video>` with no data has a natural size of 300x150, and WebKit sizes it from that —
so one with no poster of its own gets an empty SVG of the right shape as a stand-in,
without which a portrait video sits in a squat landscape box until you press play.
- **A page with a video claims the `playback` audio session** (`public/app.js`). iOS
hands inline video the ambient session, which the Ring/Silent switch mutes, so
without this the video plays and says nothing until it goes fullscreen — and the
report that arrives is "this one platform has no sound". The session only activates
once something plays, so declaring it costs nothing on a page nobody presses play on.
- **Never a bare error page.** A failed resolve renders a card carrying the platform, the - **Never a bare error page.** A failed resolve renders a card carrying the platform, the
original URL and the copy button. A broken adapter must still leave the link one tap original URL and the copy button. A broken adapter must still leave the link one tap
away. away.
@@ -144,16 +153,22 @@ Things worth knowing before editing:
Video: `fallback_url` is the *video track alone* whenever `has_audio` is true, so a Video: `fallback_url` is the *video track alone* whenever `has_audio` is true, so a
post with sound has to use `hls_url`, direct and unproxied; a silent one gets the post with sound has to use `hls_url`, direct and unproxied; a silent one gets the
proxied MP4. `scrubber_media_url` is not a poster — it is a second MP4 for the proxied MP4. `scrubber_media_url` is not a poster — it is a second MP4 for the
timeline thumbnails, and the still is in `preview.images`. A gallery's pictures are in timeline thumbnails, and the still is in `preview.images` — which belongs to both
`media_metadata`, keyed and unordered; their order is only in `gallery_data`. Comment forms: the HLS one used to go without, and showed an empty box where a silent post
showed a frame. A gallery's pictures are in `media_metadata`, keyed and unordered;
their order is only in `gallery_data`. Comment
bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put
back the constructs we chose to support, never `body_html`. An image in a comment is back the constructs we chose to support, never `body_html`. An image in a comment is
written as a token rather than an address — `![gif](giphy|Ve7wX45)`, written as a token rather than an address — `![gif](giphy|Ve7wX45)`,
`![img](emote|t5_2th52|4358)`, `![img](jo8gf0ca92zd1)` — and in every case the token is `![img](emote|t5_2th52|4358)`, `![img](jo8gf0ca92zd1)` — and the token is usually a key
a key in that same comment's own `media_metadata`, so `resolveInlineImages` is one in that same comment's own `media_metadata`, so `resolveInlineImages` is one lookup
lookup rather than three special cases. A bare `preview.redd.it` address pasted into a rather than three special cases. Usually: plenty of comments carry a Giphy token and
comment is in there too, keyed by the id inside the URL. Prefer `s.gif` over `s.mp4` no `media_metadata` at all, and Giphy is the one of the three whose id means something
for an animated one: a GIF moves in an `<img>` and an MP4 needs a player. An address off Reddit, so that token alone falls back to `i.giphy.com/media/<id>/giphy.gif`. A
variant name after the id is dropped — Giphy does not serve every variant of every
gif. A bare `preview.redd.it` address pasted into a comment is in there too, keyed by
the id inside the URL. Prefer `s.gif` over `s.mp4` for an animated one: a GIF moves in
an `<img>` and an MP4 needs a player. An address
typed without a scheme counts as well, but only when it ends in an image extension — typed without a scheme counts as well, but only when it ends in an image extension —
the rule wants a host, a path *and* that extension, because comments are full of the rule wants a host, a path *and* that extension, because comments are full of
dotted, slashed prose that must not turn into links. dotted, slashed prose that must not turn into links.
@@ -183,6 +198,10 @@ Login walls are not passed through — those need an account and cannot be solve
- **No ESLint** — `typescript-eslint` does not support TS 7 yet. `npm run typecheck` is - **No ESLint** — `typescript-eslint` does not support TS 7 yet. `npm run typecheck` is
the lint step. the lint step.
- Tests are `node:test` against captured fixtures. No network in the test suite. - Tests are `node:test` against captured fixtures. No network in the test suite.
- `public/` is served as-is to the browser, so what is in there is plain JS, not TS.
`public/browsers.js` — the per-browser URL schemes behind the "open in" picker — is
a module rather than more of `app.js` so a test can import it; that is what `allowJs`
in `tsconfig.json` is for.
- Comments explain *why*, especially where the code looks odd because a platform is odd. - Comments explain *why*, especially where the code looks odd because a platform is odd.
Match that; do not add narration of what the next line does. Match that; do not add narration of what the next line does.
- Post text comes from strangers: everything goes through the `html` tagged template or - Post text comes from strangers: everything goes through the `html` tagged template or
@@ -210,16 +229,22 @@ The registry comes from the `REGISTRY` repository variable, the image name from
`IMAGE_NAME` or the repository name, and credentials from `REGISTRY_USER` and the `IMAGE_NAME` or the repository name, and credentials from `REGISTRY_USER` and the
`REGISTRY_TOKEN` secret. Nothing about any particular deployment is committed here. `REGISTRY_TOKEN` secret. Nothing about any particular deployment is committed here.
A release also moves `package.json` on to the next patch version, committed to main by A release also moves `package.json` on to the next patch version, opened as a pull
the `bump` job — so the number in the tree is never one that has already shipped and request by the `bump` job — so the number in the tree is never one that has already
been made immutable. It lives in `publish.yml` rather than a workflow of its own so it shipped and been made immutable. It lives in `publish.yml` rather than a workflow of its
can say `needs: build`: a version that failed to publish has not been released, and own so it can say `needs: build`: a version that failed to publish has not been
bumping past it would claim otherwise. Prereleases are skipped, being candidates for a released, and bumping past it would claim otherwise. Prereleases are skipped, being
version that has not shipped. The bump goes through `npm version` rather than an edit in candidates for a version that has not shipped. The bump goes through `npm version`
place, because the version is in the lockfile too, in more than one place, and the two rather than an edit in place, because the version is in the lockfile too, in more than
have to agree. The commit carries `[skip ci]`, or pushing it would rebuild the image one place, and the two have to agree.
that was just published. Pushing to main needs a token with write access —
`VERSION_BUMP_TOKEN` overrides the task token where that one cannot. It arrives as a pull request rather than as a commit straight to main: pushing a branch
asks nothing of the task token beyond ordinary write access, so it does not matter what
protects main, and the changed `package.json` goes through a build before it lands.
Nothing pushes to main, so no `[skip ci]` is needed to stop the bump rebuilding the
image just published. The job is the only one that runs in a container (`node:22`, for
npm), and a job in a container is handed `sh`, not bash — hence the explicit
`shell: bash`, without which `set -o pipefail` fails the first line of the first step.
Two things any deployment has to get right, both learned the hard way: Two things any deployment has to get right, both learned the hard way:
+33
View File
@@ -73,6 +73,35 @@ button hands back.
`/` serves these rules with the live hostname already filled in, if you'd rather copy `/` serves these rules with the live hostname already filled in, if you'd rather copy
them from there. them from there.
## Opening the original
The rules are indiscriminate, which is the point — but that catches the way back out
too. Tapping "Open on Instagram" in the browser the rules are installed in redirects
straight back here, so the one button meant to get you to the app is the one button that
cannot.
The way past it is to hand the address to a *different* browser, by its own URL scheme.
`/` has a picker for which one:
| Browser | macOS | iOS |
| --- | --- | --- |
| Chrome | `googlechromes://…` | `googlechromes://…` |
| Edge | `microsoft-edge:https://…` | `microsoft-edge-https://…` |
| Orion | `orion://open-url?url=…` | `orion://open-url?url=…` |
| Firefox | — | `firefox://open-url?url=…` |
Firefox on macOS registers no scheme, so it is not offered there; nor is Safari itself,
which is the browser being escaped. The choice is kept in that browser's own
`localStorage` — not a cookie and not synced, because "which browser is installed" is a
fact about the device, not about you, and the phone's answer is not the Mac's.
Until a browser is chosen there is no "Open on …" button at all — the plain address is
the one thing it must not offer, since following it only comes back here. The markup
carries that address anyway, hidden, and the script swaps in the scheme and reveals the
button once there is somewhere to send it. So with JavaScript off, or with the default
left alone, the copy button and the selectable URL are the whole of it, which is all
that was ever load-bearing.
## How it works ## How it works
Every request drives a real Chromium page load. One code path, and it survives markup Every request drives a real Chromium page load. One code path, and it survives markup
@@ -142,6 +171,10 @@ The one exception is Bluesky video, which is an HLS playlist — proxying it wou
rewriting the manifest and every segment, so it is linked directly. Safari plays HLS rewriting the manifest and every segment, so it is linked directly. Safari plays HLS
natively; other browsers show a note. natively; other browsers show a note.
A page carrying a video asks iOS for the playback audio session. Without it a video
playing inline is treated as ambience and the Ring/Silent switch mutes it, so the post
plays perfectly and says nothing unless you go fullscreen.
Resolved posts are cached in memory for an hour, so a reload or a back button doesn't Resolved posts are cached in memory for an hour, so a reload or a back button doesn't
drive the browser again. drive the browser again.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "antisocial", "name": "antisocial",
"version": "1.2.1", "version": "1.2.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "antisocial", "name": "antisocial",
"version": "1.2.1", "version": "1.2.4",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@fastify/static": "10.1.3", "@fastify/static": "10.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "antisocial", "name": "antisocial",
"version": "1.2.1", "version": "1.2.4",
"private": true, "private": true,
"description": "Reads social posts back to you without the app.", "description": "Reads social posts back to you without the app.",
"license": "UNLICENSED", "license": "UNLICENSED",
+24
View File
@@ -242,6 +242,30 @@ main { max-width: 680px; margin: 0 auto; }
.index__table td { padding: 8px 8px 8px 0; border-top: 1px solid var(--line); vertical-align: middle; } .index__table td { padding: 8px 8px 8px 0; border-top: 1px solid var(--line); vertical-align: middle; }
.index__table code { overflow-wrap: anywhere; } .index__table code { overflow-wrap: anywhere; }
.picker {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
/* The control ships hidden and is revealed by the script, and `display:
flex` above would otherwise beat the browser's own rule for [hidden]. */
.picker[hidden] { display: none; }
.picker__label { font-size: 13px; color: var(--ink-dim); }
.picker__select {
font: inherit;
font-size: 14px;
padding: 7px 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--card);
color: var(--ink);
}
.picker__note { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--ink-dim); }
/* ---------- verification puzzle ---------- */ /* ---------- verification puzzle ---------- */
.challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); } .challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); }
+75
View File
@@ -1,7 +1,10 @@
// Progressive enhancement only. Without this file the page still shows the // Progressive enhancement only. Without this file the page still shows the
// media in a swipeable rail and the original URL as selectable text. // media in a swipeable rail and the original URL as selectable text.
import { browsersFor, detectOs, openUrlFor } from './browsers.js';
const VIEW_KEY = 'antisocial:view'; const VIEW_KEY = 'antisocial:view';
const BROWSER_KEY = 'antisocial:browser';
function setupCopy() { function setupCopy() {
for (const button of document.querySelectorAll('.copy')) { for (const button of document.querySelectorAll('.copy')) {
@@ -37,6 +40,62 @@ function setupCopy() {
} }
} }
function storedBrowser() {
try {
return localStorage.getItem(BROWSER_KEY) ?? 'default';
} catch {
return 'default';
}
}
// The markup carries the plain address, which is the one thing the button
// must not offer: followed in the browser the rewrite rules are installed
// in, it redirects straight back to this page. So the link ships hidden and
// is only revealed once a browser has been picked to hand it to. With no
// choice made -- or none this system has a scheme for -- the copy button
// and the URL below it are the whole of it.
function applyBrowser(id) {
const os = detectOs();
for (const link of document.querySelectorAll('.original__open')) {
link.dataset.original ??= link.getAttribute('href');
const href = openUrlFor(link.dataset.original, id, os);
link.href = href;
link.hidden = href === link.dataset.original;
}
}
// Only on the index page, and only with this file running: the choice is
// useless without the rewriting above, so the control ships hidden.
function setupBrowserPicker() {
const select = document.querySelector('.picker__select');
if (!select) return;
for (const browser of browsersFor(detectOs())) {
const option = document.createElement('option');
option.value = browser.id;
option.textContent = browser.label;
select.append(option);
}
// A stored id with no scheme on this system would leave the control
// showing nothing at all, so fall back rather than render a blank.
const stored = storedBrowser();
select.value = [...select.options].some((option) => option.value === stored) ? stored : 'default';
select.addEventListener('change', () => {
try {
localStorage.setItem(BROWSER_KEY, select.value);
} catch {
// Private browsing. The choice just won't survive the page.
}
applyBrowser(select.value);
});
select.closest('.picker').hidden = false;
}
// One per rail: a post can carry several -- a thread of them, or a post and // One per rail: a post can carry several -- a thread of them, or a post and
// the post it quotes -- and wiring only the first leaves the rest inert. // the post it quotes -- and wiring only the first leaves the rest inert.
function setupMedia(media) { function setupMedia(media) {
@@ -96,6 +155,19 @@ function setupMedia(media) {
} }
} }
// On iOS a video playing inline gets the "ambient" audio session, which the
// Ring/Silent switch mutes -- so the post plays perfectly and says nothing,
// and the only way to hear it is to go fullscreen. Claiming "playback" says
// what is true here: the sound is the point, not decoration. The session is
// not activated until something actually plays, so declaring it up front
// interrupts nothing; it is declared only on a page that has a video so an
// ordinary text post never claims it at all.
function setupAudioSession() {
if (!('audioSession' in navigator)) return;
if (!document.querySelector('video')) return;
navigator.audioSession.type = 'playback';
}
// Every comment is a <details open>, so folding one already works with this // Every comment is a <details open>, so folding one already works with this
// file missing. All this adds is doing the whole page at once, which is why // file missing. All this adds is doing the whole page at once, which is why
// the button ships hidden and is only revealed here. // the button ships hidden and is only revealed here.
@@ -127,7 +199,10 @@ function setupComments() {
} }
setupCopy(); setupCopy();
applyBrowser(storedBrowser());
setupBrowserPicker();
for (const media of document.querySelectorAll('.media')) setupMedia(media); for (const media of document.querySelectorAll('.media')) setupMedia(media);
setupAudioSession();
setupComments(); setupComments();
// --- Verification puzzles ------------------------------------------------- // --- Verification puzzles -------------------------------------------------
+64
View File
@@ -0,0 +1,64 @@
// The rewrite rules that send a shared link here catch the original link on
// the way back out too, so in Safari "Open on <platform>" lands back on this
// page. Handing the address to a different browser is the way out, and the
// only way to do that from a web page is that browser's own URL scheme --
// which every one of them spells differently. Some swap the scheme, some
// prefix it, some take the whole address as a query parameter.
/** http -> googlechrome://, https -> googlechromes:// */
const chrome = (url) => url.replace(/^http(s?):/, 'googlechrome$1:');
// Edge keeps the original scheme either way, but on macOS it stays in the
// address and the scheme is prefixed, while on iOS it is folded into the
// scheme itself.
const edgeMacos = (url) => `microsoft-edge:${url}`;
const edgeIos = (url) => url.replace(/^http(s?):/, 'microsoft-edge-http$1:');
/** Firefox and Orion take the address as a parameter instead. */
const openUrl = (scheme) => (url) => `${scheme}://open-url?url=${encodeURIComponent(url)}`;
// `null` means the browser is there but ships no scheme to reach it on that
// system, so there is nothing to offer beyond the plain link.
export const BROWSERS = [
{ id: 'default', label: 'Default browser', macos: null, ios: null },
{ id: 'chrome', label: 'Chrome', macos: chrome, ios: chrome },
{ id: 'edge', label: 'Edge', macos: edgeMacos, ios: edgeIos },
{ id: 'firefox', label: 'Firefox', macos: null, ios: openUrl('firefox') },
{ id: 'orion', label: 'Orion', macos: openUrl('orion'), ios: openUrl('orion') },
];
/**
* iPadOS calls itself MacIntel, so the platform string alone cannot tell the
* two apart; the touch points can. Everything else gets the desktop table --
* the only desktop this is ever opened on is a Mac.
*
* @param {{ platform?: string, maxTouchPoints?: number }} [nav]
*/
export function detectOs(nav = globalThis.navigator) {
const platform = nav?.platform ?? '';
if (/^iP(hone|ad|od)/.test(platform)) return 'ios';
if (platform === 'MacIntel' && (nav?.maxTouchPoints ?? 0) > 1) return 'ios';
return 'macos';
}
/** The browsers worth offering here: the default, plus the ones this system
* actually has a scheme for. */
export function browsersFor(os) {
return BROWSERS.filter((browser) => browser.id === 'default' || browser[os]);
}
function browserById(id) {
return BROWSERS.find((browser) => browser.id === id);
}
/**
* The address to open. A browser with no scheme on this system, an unknown
* id, or anything that is not an ordinary web link is handed back untouched:
* a dead custom scheme opens nothing at all, which is worse than the plain
* link opening in the wrong browser.
*/
export function openUrlFor(url, browserId, os) {
const rewrite = browserById(browserId)?.[os];
if (!rewrite || !/^https?:\/\//i.test(url)) return url;
return rewrite(url);
}
+35 -18
View File
@@ -104,6 +104,10 @@ function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[
const common = { const common = {
...sized(video.width, video.height), ...sized(video.width, video.height),
...(video.duration ? { durationSec: video.duration } : {}), ...(video.duration ? { durationSec: video.duration } : {}),
// The still belongs to the video and not to the format it is served in.
// The HLS branch used to drop it, which is why a post with sound showed
// an empty box where every silent one showed a frame.
...(poster ? { poster: { url: poster } } : {}),
}; };
if (video.has_audio !== false && video.hls_url) { if (video.has_audio !== false && video.hls_url) {
@@ -114,12 +118,7 @@ function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[
if (url === video.hls_url) { if (url === video.hls_url) {
return [{ kind: 'video', url, hls: true, direct: true, ...common }]; return [{ kind: 'video', url, hls: true, direct: true, ...common }];
} }
return [{ return [{ kind: 'video', url, ...common }];
kind: 'video',
url,
...(poster ? { poster: { url: poster } } : {}),
...common,
}];
} }
/** One entry of a gallery post. Reddit keeps the pictures somewhere other /** One entry of a gallery post. Reddit keeps the pictures somewhere other
@@ -192,14 +191,30 @@ function bodyOf(link: Link): string | undefined {
/** The whole of `![...](...)`, with the target captured. */ /** The whole of `![...](...)`, with the target captured. */
const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g; const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g;
/**
* A Giphy token, which unlike the others says what it points at.
*
* Reddit writes a variant name after the id on some of them
* (`giphy|abc123|downsized`); only the id is kept, because Giphy does not
* serve every variant for every gif but always serves the full one.
*/
const GIPHY_TOKEN = /^giphy\|([A-Za-z0-9]+)(?:\|[a-z_]+)?$/;
/** /**
* Point a comment's inline images at something fetchable. * Point a comment's inline images at something fetchable.
* *
* Reddit writes them as `![gif](giphy|Ve7wX45)`, `![img](emote|t5_2th52|4358)` * Reddit writes them as `![gif](giphy|Ve7wX45)`, `![img](emote|t5_2th52|4358)`
* or `![img](jo8gf0ca92zd1)` — a token rather than an address. In every case * or `![img](jo8gf0ca92zd1)` — a token rather than an address. Usually the
* the token is a key in that same comment's `media_metadata`, which is where * token is a key in that same comment's `media_metadata`, which is where the
* the real URL is, so one lookup covers all three and none of them needs * real URL is, so one lookup covers all three and none of them needs naming
* naming here. * here.
*
* Usually, not always: Reddit ships plenty of comments carrying a Giphy token
* and no `media_metadata` at all, and with nothing to look the token up in
* those showed the token itself where the gif should have been. Giphy is the
* one kind that can be resolved without the lookup, the id in it being Giphy's
* own, so it falls back to Giphy's address for that id. The other two cannot:
* their ids mean nothing off Reddit.
* *
* A target that is already an address is not a key, so it falls through * A target that is already an address is not a key, so it falls through
* untouched. * untouched.
@@ -208,15 +223,17 @@ export function resolveInlineImages(
body: string, body: string,
meta: Record<string, MediaMeta> | undefined, meta: Record<string, MediaMeta> | undefined,
): string { ): string {
if (!meta) return body;
return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => { return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => {
const entry = meta[token]; const entry = meta?.[token];
if (!entry || entry.status !== 'valid') return whole; if (entry?.status === 'valid') {
// An animated one has both; the GIF plays in an `<img>` on its own, which // An animated one has both; the GIF plays in an `<img>` on its own,
// an MP4 does not. // which an MP4 does not.
const url = entry.s?.gif ?? entry.s?.u; const url = entry.s?.gif ?? entry.s?.u;
return url ? `![${alt}](${url})` : whole; if (url) return `![${alt}](${url})`;
}
const giphy = GIPHY_TOKEN.exec(token)?.[1];
return giphy ? `![${alt}](https://i.giphy.com/media/${giphy}/giphy.gif)` : whole;
}); });
} }
+1 -1
View File
@@ -28,7 +28,7 @@ export function renderChallenge(challenge: Challenge, box: Box | undefined): str
<footer class="post__foot"> <footer class="post__foot">
<div class="original"> <div class="original">
<button type="button" class="copy" id="give-up">Give up and show me the link</button> <button type="button" class="copy" id="give-up">Give up and show me the link</button>
<a class="original__open" href="${challenge.originalUrl}" rel="noopener noreferrer nofollow" target="_blank"> <a class="original__open" href="${challenge.originalUrl}" rel="noopener noreferrer nofollow" target="_blank" hidden>
Open on ${challenge.platformLabel} Open on ${challenge.platformLabel}
</a> </a>
<p class="original__url"><code>${challenge.originalUrl}</code></p> <p class="original__url"><code>${challenge.originalUrl}</code></p>
+8
View File
@@ -30,6 +30,14 @@ export function renderIndex(platforms: readonly PlatformSpec[]): string {
)} )}
</tbody> </tbody>
</table> </table>
<section class="picker" hidden>
<label class="picker__label" for="open-in">Open original links in</label>
<select class="picker__select" id="open-in"></select>
<p class="picker__note">
The rewrite rules catch the original link too, so in Safari “Open on …” comes
straight back here. Another browser is a way out of that. Kept on this device.
</p>
</section>
</article>`; </article>`;
return layout('antisocial', body); return layout('antisocial', body);
+7 -2
View File
@@ -43,14 +43,19 @@ export function badge(platform: string, label: string): Raw {
} }
/** The copy control, plus the URL itself so it is always selectable even if /** The copy control, plus the URL itself so it is always selectable even if
* the clipboard API is unavailable. */ * the clipboard API is unavailable.
*
* The open link ships hidden: followed in the browser the rewrite rules are
* installed in, it redirects straight back here, so it is only worth showing
* once a browser has been picked to hand it to. The script reveals it, and
* the href in the markup is the plain address it starts from. */
export function originalUrlBlock(originalUrl: string, platformLabel: string): Raw { export function originalUrlBlock(originalUrl: string, platformLabel: string): Raw {
return html`<div class="original"> return html`<div class="original">
<button type="button" class="copy" data-url="${originalUrl}"> <button type="button" class="copy" data-url="${originalUrl}">
<span class="copy__idle">Copy original link</span> <span class="copy__idle">Copy original link</span>
<span class="copy__done" hidden>Copied</span> <span class="copy__done" hidden>Copied</span>
</button> </button>
<a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank">Open on ${platformLabel}</a> <a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank" hidden>Open on ${platformLabel}</a>
<p class="original__url"><code>${originalUrl}</code></p> <p class="original__url"><code>${originalUrl}</code></p>
</div>`; </div>`;
} }
+19 -1
View File
@@ -14,11 +14,29 @@ function aspect(item: Media): string {
return item.width && item.height ? `aspect-ratio: ${item.width} / ${item.height};` : ''; return item.width && item.height ? `aspect-ratio: ${item.width} / ${item.height};` : '';
} }
/**
* A stand-in poster carrying nothing but the video's shape.
*
* The ratio above is not enough on its own before the video has any data: a
* `<video>` in that state has a natural size of 300x150, and WebKit sizes it
* from that rather than from the ratio, so a portrait video sat in a squat
* landscape box until you pressed play and it snapped to shape. A video's
* size before its data arrives is its poster's, which makes an empty SVG of
* the right shape enough to put the box right, and as a data URI it costs no
* request. Only for a video the platform gave no poster for, since a real one
* already says the same thing.
*/
function placeholderPoster(item: Media): string | undefined {
if (!item.width || !item.height) return undefined;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${item.width}" height="${item.height}"/>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
function renderItem(item: Media, index: number): Raw { function renderItem(item: Media, index: number): Raw {
const src = proxyUrlFor(item); const src = proxyUrlFor(item);
if (item.kind === 'video') { if (item.kind === 'video') {
const poster = item.poster ? proxyUrlFor(item.poster) : undefined; const poster = item.poster ? proxyUrlFor(item.poster) : placeholderPoster(item);
return html`<figure class="item item--video" data-index="${index}"> return html`<figure class="item item--video" data-index="${index}">
<video <video
controls controls
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { browsersFor, detectOs, openUrlFor } from '../public/browsers.js';
const URL = 'https://www.instagram.com/p/ABC/?a=1&b=2';
test('each browser gets the scheme it actually answers to', () => {
assert.equal(openUrlFor(URL, 'chrome', 'macos'), 'googlechromes://www.instagram.com/p/ABC/?a=1&b=2');
assert.equal(openUrlFor(URL, 'chrome', 'ios'), 'googlechromes://www.instagram.com/p/ABC/?a=1&b=2');
assert.equal(openUrlFor('http://example.com/a', 'chrome', 'ios'), 'googlechrome://example.com/a');
// Edge folds the scheme into its own on iOS and prefixes it on macOS.
assert.equal(openUrlFor(URL, 'edge', 'macos'), `microsoft-edge:${URL}`);
assert.equal(openUrlFor(URL, 'edge', 'ios'), 'microsoft-edge-https://www.instagram.com/p/ABC/?a=1&b=2');
// The parameter form has to be encoded, or the original query string ends
// up read as the opener's own.
assert.equal(openUrlFor(URL, 'orion', 'macos'),
`orion://open-url?url=${encodeURIComponent(URL)}`);
assert.equal(openUrlFor(URL, 'firefox', 'ios'),
`firefox://open-url?url=${encodeURIComponent(URL)}`);
});
test('a browser with no scheme on this system keeps the plain link', () => {
// Firefox on macOS has none, and a dead scheme opens nothing at all --
// worse than opening in the wrong browser.
assert.equal(openUrlFor(URL, 'firefox', 'macos'), URL);
assert.equal(openUrlFor(URL, 'default', 'ios'), URL);
assert.equal(openUrlFor(URL, 'nonesuch', 'ios'), URL);
});
test('only ordinary web links are rewritten', () => {
assert.equal(openUrlFor('mailto:[email protected]', 'chrome', 'ios'), 'mailto:[email protected]');
assert.equal(openUrlFor('/reddit/r/a/comments/b', 'chrome', 'ios'), '/reddit/r/a/comments/b');
});
test('only the browsers reachable on that system are offered', () => {
assert.deepEqual(browsersFor('macos').map((b) => b.id), ['default', 'chrome', 'edge', 'orion']);
assert.deepEqual(browsersFor('ios').map((b) => b.id), ['default', 'chrome', 'edge', 'firefox', 'orion']);
});
test('an iPad is told from a Mac by its touch points, not its platform string', () => {
assert.equal(detectOs({ platform: 'iPhone', maxTouchPoints: 5 }), 'ios');
assert.equal(detectOs({ platform: 'MacIntel', maxTouchPoints: 5 }), 'ios');
assert.equal(detectOs({ platform: 'MacIntel', maxTouchPoints: 0 }), 'macos');
assert.equal(detectOs({}), 'macos');
});
+55 -1
View File
@@ -56,6 +56,7 @@ test('a video with sound is the HLS playlist, because the MP4 has no audio track
duration: 42, duration: 42,
}, },
}, },
preview: { images: [{ source: { url: 'https://external-preview.redd.it/still.png', width: 1920, height: 1080 } }] },
}); });
assert.deepEqual(media, [ assert.deepEqual(media, [
@@ -67,6 +68,9 @@ test('a video with sound is the HLS playlist, because the MP4 has no audio track
width: 1920, width: 1920,
height: 1080, height: 1080,
durationSec: 42, durationSec: 42,
// The still is the video's, not the MP4's. Dropping it here left every
// post with sound showing an empty box where a silent one showed a frame.
poster: { url: 'https://external-preview.redd.it/still.png' },
}, },
]); ]);
}); });
@@ -224,13 +228,63 @@ test('a target that is already an address is left alone', () => {
}); });
test('a token with no entry, or a broken one, is not invented', () => { test('a token with no entry, or a broken one, is not invented', () => {
assert.equal(resolveInlineImages('![gif](giphy|missing)', GIPHY), '![gif](giphy|missing)'); // An upload id and an emote id mean nothing off Reddit, so with no entry to
// look them up in there is nothing to point them at.
assert.equal(resolveInlineImages('![img](missing)', GIPHY), '![img](missing)');
assert.equal( assert.equal(
resolveInlineImages('![img](gone)', { gone: { status: 'failed', e: 'Image' } }), resolveInlineImages('![img](gone)', { gone: { status: 'failed', e: 'Image' } }),
'![img](gone)', '![img](gone)',
); );
}); });
test('a giphy token resolves even when the comment carried no metadata', () => {
// Reddit ships plenty of these with no `media_metadata` at all. The id in
// the token is Giphy's own, so it does not need Reddit to be readable.
assert.equal(
resolveInlineImages('![gif](giphy|zUW23b6FmzB5e)', undefined),
'![gif](https://i.giphy.com/media/zUW23b6FmzB5e/giphy.gif)',
);
assert.equal(
resolveInlineImages('![gif](giphy|missing)', GIPHY),
'![gif](https://i.giphy.com/media/missing/giphy.gif)',
);
});
test('the metadata still wins where there is any, being what Reddit will serve', () => {
assert.match(resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY), /redd\.it/);
});
test('a variant name after the id is dropped, not all of them being served', () => {
assert.equal(
resolveInlineImages('![gif](giphy|zUW23b6FmzB5e|downsized)', undefined),
'![gif](https://i.giphy.com/media/zUW23b6FmzB5e/giphy.gif)',
);
});
test('only a giphy token is guessed at, and only a well-formed one', () => {
for (const token of ['emote|t5_2th52|4358', 'giphy|', 'giphy|../evil', 'giphy|a|b|c']) {
assert.equal(resolveInlineImages(`![x](${token})`, undefined), `![x](${token})`);
}
});
test('a real comment carrying a giphy token gets the gif', () => {
// Captured from the post itself: the comment has the token and no
// `media_metadata`, which is the shape that used to show the token instead.
const post = toPost(fixture('reddit/video.json'), URL_);
const all: string[] = [];
const walk = (list: typeof post.comments) => {
for (const comment of list ?? []) {
if (comment.text) all.push(comment.text);
walk(comment.replies);
}
};
walk(post.comments);
const gif = all.find((text) => text.includes('giphy'));
assert.ok(gif, 'the fixture should still carry a giphy comment');
assert.match(gif, /!\[gif\]\(https:\/\/i\.giphy\.com\/media\/QfzMP70zmNQiDf5sGP\/giphy\.gif\)/);
});
test('inline images survive the walk into the comment tree', () => { test('inline images survive the walk into the comment tree', () => {
const { comments } = commentsFrom({ const { comments } = commentsFrom({
data: { data: {
+26 -1
View File
@@ -90,6 +90,27 @@ test('a video gets native controls and a source, not an iframe', () => {
assert.ok(!page.includes('<iframe')); assert.ok(!page.includes('<iframe'));
}); });
test('a video with no poster still carries its shape, so the box is right before play', () => {
// WebKit sizes a video from its natural size, which without data or a
// poster is 300x150 — a portrait video sat in a landscape box until you
// pressed play. The poster is where the shape comes from until then.
const page = renderPost(withMedia([
{ kind: 'video', url: 'https://video.example/p.m3u8', hls: true, direct: true, width: 720, height: 1280 },
]));
assert.ok(page.includes('aspect-ratio: 720 / 1280;'));
assert.match(page, /poster="data:image\/svg\+xml,[^"]*width%3D%22720%22[^"]*height%3D%221280%22/);
});
test('a real poster is left in place, and an unmeasured video gets none', () => {
const withPoster = renderPost(withMedia([
{ kind: 'video', url: 'https://cdn/v.mp4', width: 720, height: 1280, poster: { url: 'https://cdn/p.jpg' } },
]));
assert.match(withPoster, /poster="\/m\//);
assert.ok(!withPoster.includes('data:image/svg'));
assert.ok(!renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }])).includes('poster='));
});
test('the copy button carries the clean original URL, and so does the page text', () => { test('the copy button carries the clean original URL, and so does the page text', () => {
const page = renderPost(post()); const page = renderPost(post());
assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"')); assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"'));
@@ -105,8 +126,12 @@ test('a failure still hands the link back', () => {
detail: 'TikTok showed a verification puzzle instead of the post.', detail: 'TikTok showed a verification puzzle instead of the post.',
}); });
assert.ok(page.includes('data-url="https://www.tiktok.com/@a/video/1"')); assert.ok(page.includes('data-url="https://www.tiktok.com/@a/video/1"'));
assert.ok(page.includes('Open on TikTok')); assert.ok(page.includes('<code>https://www.tiktok.com/@a/video/1</code>'));
assert.ok(page.includes('verification puzzle')); assert.ok(page.includes('verification puzzle'));
// Following the original link in the browser the rewrite rules are
// installed in only comes back here, so it stays hidden until the script
// has a browser to hand it to.
assert.ok(/<a class="original__open"[^>]* hidden>Open on TikTok<\/a>/.test(page));
}); });
function redditPost(overrides: Partial<Post> = {}): Post { function redditPost(overrides: Partial<Post> = {}): Post {
+4
View File
@@ -17,6 +17,10 @@
// Source imports carry the real `.ts` specifier so `node --experimental- // Source imports carry the real `.ts` specifier so `node --experimental-
// strip-types` can run the tree directly for dev, tests and the resolve // strip-types` can run the tree directly for dev, tests and the resolve
// CLI. tsc rewrites them to `.js` on the way into dist. // CLI. tsc rewrites them to `.js` on the way into dist.
// The client-side scheme table is plain JS, because the browser loads it
// as-is out of `public`. Its types are inferred so the test can check it.
"allowJs": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,