10 Commits

Author SHA1 Message Date
9f56436033 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:42:21 +00:00
ae900b82ca Name the icons by their contents in the pages (#57)
/favicon.png, /apple-touch-icon.png and /logo.svg are kept a day under
fixed names, so after the new logo went out, curl through the tunnel and
browsers still got the old one. The pages now ask for them as
/favicon.png?v=<hash>, the way they already ask for app.js and app.css,
so a changed icon is a new URL for every cache on the way. /favicon.ico
cannot carry a query, as browsers ask for it on their own; it keeps the
day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:11:03 +00:00
40c92ad08d Rename iPodderX to iPX (#56)
The app, the repository (rays/ipx), the image, the compose service and
container, and the data folders on Tower take the new name. What stays:
the 2004 iPodderX and ipodderx-core, which are history; the Postgres
database and login, and ipodderx.sdf1.net with its Access and Authentik
apps, which would each need moving outside this repo; and the first-start
password, as ipx is shorter than the eight characters a password needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:57:02 +00:00
7796566dfc A logo drawn from the radio's screen, to app icon guidelines (#55)
The 2004 icon is a whole radio on a transparent background and not
square, so the tab icon was padded and iOS painted the home-screen icon
on white. web/logo.svg is the radio's screen alone, its tuning scale and
orange needle, laid out as Apple's app icon guidelines ask: opaque and
full-bleed (the system cuts its own corners), a gradient background and
flat foreground layers with hard edges, no highlights or shadows of its
own, nothing thin enough to vanish at 32px. Each layer is a <g>, ready to
split out for Icon Composer. web/logo-dark.svg is the same layers
recoloured.

favicon.png (128) and apple-touch-icon.png (180) are renders of it. The
pages show it from /logo.svg, served outside the auth layer for the
sign-in page, with an app icon's rounded corners. The 2004 icon stays at
/icon.png for anything outside that links to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:16 +00:00
162665b010 Phone layout drawn the way iOS draws its apps (#53), clear of the Dynamic Island (#54)
On a phone every control was outlined, hairlines ruled off the bars, corners were 7-9px and
dialogs were centred cards: a desktop page, shrunk. Now, below 820px and in every theme, controls
are filled and round (40px circles and capsules, pills for groups), the feed's name is a 26px
large title, the tabs are a segmented control, rows are 44px with 17px text and an inset hairline,
the reader's type is 17px, and dialogs are sheets from the bottom. --edge is the one switch for the
outlines, left on for the high-contrast theme and for prefers-contrast:more. Glass's desktop radii
moved into a min-width query, because at their specificity they outranked the phone's shapes, and
on a phone its #51 rim only catches the top edge: all round a small capsule it read as an outline.

Text boxes were 13.5px, and Safari zooms the page in on focus below 16px, so tapping search zoomed
it; they are 16-17px on a phone now.

Nothing read safe-area-inset-top, so opened from the home screen the top bar, the reader's back
button and the drawer's head sat under the Dynamic Island. --safe-t pads them, and the phone's own
top-bar rule, a shorthand, had also been discarding the side insets for the notch in landscape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:48:15 +00:00
f2fde8cc75 Swipe between items like turning pages (#52)
On a phone the reader is fixed over the item list, and the swipe from #49 moved it alone and
faded it to 40%, so the list showed through it and in the strip it uncovered, and again as the
next item slid in from the far side. Now a layer beside the reader, #dpeek, holds what is really
there: the next or previous item, drawn by the same detailHtml the reader uses, with 16px of the
page's background between them; "No more items" past the last; or, swiping right from the
first, a dimmer over the list that lifts as the reader, shadowed along its edge, is drawn off.
On release the swipe carries on from where the finger left it, over 120-250ms by how far is
left, and the neighbour becomes the reader in place; with reduced motion it switches at once.

A touch starting within 14px of the left edge is kept from Safari, which otherwise takes it as
Back and leaves the page mid-swipe. 14px because the back button starts at 16.

Offsets are rounded to whole pixels: at a fractional offset the seam between the reader and its
neighbour drew a stray light line.

The design had the list shift a third of the way across as it is uncovered, as iOS does. #detail
lives inside #shell, and a transform there makes it the containing block for the fixed reader,
so that part is left out; the dimmer and shadow carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:46:07 +00:00
a573a07ed5 Glass panels catch the light at their edges (#51)
The glass surfaces had one 1px highlight along the top, which read as flat. They now have a rim
lit from the top-left and a fainter one on the far edges, as box-shadows, and a sheen from the
top-left corner as a background layer. A pseudo-element would have been the usual way, but the
detail pane, file list and dialogs scroll, and an absolutely placed layer in a scroller scrolls
away with the content.

Refraction was looked at and left out again: bending the live page needs backdrop-filter: url(),
which Firefox and Safari lack, and the WebGL libraries (liquid-glass-js, liquidGL) bend a
snapshot of the page that goes stale on a list that scrolls in its own pane.

The sheen lightens a dark panel under its text, so tests/contrast.js now checks every text colour
on a panel under the sheen at full strength. That capped it at 7% in dark mode; 9% put --faint,
--accent and --bad under AA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:25:24 +00:00
380a552913 Share a feed, an item or a file (#48)
A share button in the feed's header, beside an item's "Open the original", and on each file.
It uses the Web Share API where the browser has it, which is the share sheet on a phone, and
copies the link where it does not. A file is shared by the publisher's address, not /media/,
which only someone signed in here can open.

Paid feeds carry the subscriber's access in their address, Patreon's in a token, so sharing one
gives the subscription away. An address that looks like that asks first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:17:52 +00:00
bdda9b3d2e Block lists: words that hide items and keep them from downloading (#47)
Each person has a list for every feed they read and one per feed. An item whose title or text
holds one of the words, matched as whole words so "ai" does not hide everything that "said"
anything, is hidden from them and, since the scanner now keeps each subscriber's filters
separate, is fetched only if someone else still wants it.

Whole-word matching is not something LIKE can do on both SQLite and Postgres, so the matches are
worked out in Rust into a `hidden` table whenever a list changes, someone subscribes, or a scan
brings in new items, and the queries only look that table up. Both new tables are tables rather
than columns because create_missing adds tables but never columns. Hidden counts as read for
the reaper and for "others still want this file", since whoever it is hidden from is as done
with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:13:03 +00:00
868dd673f3 Swipes take a longer drag and slide the reader across (#49)
At a flat 60px, a thumb scrolling slightly on the diagonal moved on to the next item by
accident. A swipe now has to cover a quarter of the reader's width, and never less than 100px,
and the reader follows the finger while it is down, so it is plain before letting go whether it
will move on. It slides off on a swipe and the next item slides in from the other side; a short
one springs back. The CHANGELOG also gets back the [0.8.3] link a stray edit had broken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:04:30 +00:00
28 changed files with 852 additions and 166 deletions

View File

@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.9.0] - 2026-09-28
### Added
- A new logo, on every page, in the browser tab and on the home screen: the old radio's screen,
its tuning scale and orange needle, drawn flat to Apple's app icon guidelines, with a dark
version. The 2004 icon is still at /icon.png.
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
list of its own; an item with one of those words or phrases in its title or text is hidden
from you and is not downloaded on your account. Other people's lists never hide anything from
you.
- Share buttons for a feed, an item and a file. On a phone they open the system's share sheet;
elsewhere they copy the link. A link that looks like it carries your own access to a paid
feed asks before it goes anywhere.
### Changed
- iPodderX is now called iPX, on every page and in the tab. The repository is rays/ipx.
- On a phone, iPodderX looks like an iOS app: filled round buttons and capsules instead of
outlined boxes, no rules between the bars and the page, the feed's name as a large title, a
segmented control for the tabs, taller rows with 17px text, and dialogs as sheets from the
bottom. Tapping a text box no longer zooms the page in. High contrast, and Increase Contrast,
keep their outlines.
- The Glass theme's panels catch the light: a bright rim along the top and left edges, a fainter
one along the far edges, and a soft sheen from the top-left corner.
### Fixed
- A new tab or home-screen icon shows up as soon as it is deployed, instead of a day later.
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
them.
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a
diagonal scroll no longer jumps ahead by accident. The item follows your finger and slides
across as it changes.
- Swiping between items on a phone no longer shows the item list through the reader. The next
or previous item sits beside the one you are reading and comes across with it, the end of the
list says so, and swiping right from the first item draws the reader off the list, dimmed
until it is uncovered. A swipe from the screen's left edge stays with the page, rather than
Safari taking it as Back.
## [0.8.4] - 2026-09-19 ## [0.8.4] - 2026-09-19
### Added ### Added
@@ -572,22 +613,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout. - Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`. - `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.4...main [unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...main
[0.8.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.3...v0.8.4 [0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3 [0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2 [0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1 [0.8.2]: https://git.sdf1.net/rays/ipx/compare/v0.8.1...v0.8.2
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0 [0.8.1]: https://git.sdf1.net/rays/ipx/compare/v0.8.0...v0.8.1
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0 [0.8.0]: https://git.sdf1.net/rays/ipx/compare/v0.7.0...v0.8.0
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1 [0.7.0]: https://git.sdf1.net/rays/ipx/compare/v0.6.1...v0.7.0
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0 [0.6.1]: https://git.sdf1.net/rays/ipx/compare/v0.6.0...v0.6.1
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5 [0.6.0]: https://git.sdf1.net/rays/ipx/compare/v0.5.5...v0.6.0
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4 [0.5.5]: https://git.sdf1.net/rays/ipx/compare/v0.5.4...v0.5.5
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3 [0.5.4]: https://git.sdf1.net/rays/ipx/compare/v0.5.3...v0.5.4
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2 [0.5.3]: https://git.sdf1.net/rays/ipx/compare/v0.5.2...v0.5.3
[0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1 [0.5.2]: https://git.sdf1.net/rays/ipx/compare/v0.5.1...v0.5.2
[0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0 [0.5.1]: https://git.sdf1.net/rays/ipx/compare/v0.5.0...v0.5.1
[0.4.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...v0.4.0 [0.5.0]: https://git.sdf1.net/rays/ipx/compare/v0.4.0...v0.5.0
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0 [0.4.0]: https://git.sdf1.net/rays/ipx/compare/v0.3.0...v0.4.0
[0.2.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.1.0...v0.2.0 [0.3.0]: https://git.sdf1.net/rays/ipx/compare/v0.2.0...v0.3.0
[0.1.0]: https://git.sdf1.net/rays/ipodderx-rs/releases/tag/v0.1.0 [0.2.0]: https://git.sdf1.net/rays/ipx/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.sdf1.net/rays/ipx/releases/tag/v0.1.0

View File

@@ -1,4 +1,4 @@
# Working on ipodderx-rs # Working on ipx
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
thing is built; this file is about working on it without repeating mistakes that have already been thing is built; this file is about working on it without repeating mistakes that have already been
@@ -6,22 +6,22 @@ made here.
## Where things are ## Where things are
Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs; Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production. `docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
| | Host | In the container | | | Host | In the container |
|---|---|---| |---|---|---|
| Image | `192.168.1.130:5000/ipodderx:latest` | | | Image | `192.168.1.130:5000/ipx:latest` | |
| Config | `/mnt/fast/appdata/ipodderx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` | | Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipodderx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipodderx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | | | Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` | | Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` | | Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` | | Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) | | Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`. `TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned **Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
@@ -30,7 +30,7 @@ when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once
`main` and pushed, comment on the issue with what changed and the commit, then close it: `main` and pushed, comment on the issue with what changed and the commit, then close it:
```sh ```sh
R="--login git.sdf1.net --repo rays/ipodderx-rs" R="--login git.sdf1.net --repo rays/ipx"
t() { timeout 30 /src/tea "$@" < /dev/null; } t() { timeout 30 /src/tea "$@" < /dev/null; }
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found." t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3." t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
@@ -46,10 +46,10 @@ for later stays open, and that is how it gets picked up again.
Deploying a change is: build and push the image, then pull it and recreate the container. Deploying a change is: build and push the image, then pull it and recreate the container.
```sh ```sh
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipx
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipx
docker logs --tail 20 iPodderX docker logs --tail 20 iPX
``` ```
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich **Name the service.** A bare `up -d` recreates every container in `content`, beets and immich

2
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.8.4" version = "0.9.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "0.8.4" version = "0.9.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -1,4 +1,4 @@
# ipodderx-rs # ipx
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
@@ -30,7 +30,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
With Docker: With Docker:
```sh ```sh
docker build -t ipodderx . docker build -t ipx .
docker compose up -d docker compose up -d
``` ```

View File

@@ -1,7 +1,7 @@
services: services:
ipodderx: ipx:
image: 192.168.1.130:5000/ipodderx:latest image: 192.168.1.130:5000/ipx:latest
container_name: iPodderX container_name: iPX
restart: unless-stopped restart: unless-stopped
environment: environment:
PUID: "99" PUID: "99"
@@ -10,15 +10,15 @@ services:
IPX_LOG: "ipx=info" IPX_LOG: "ipx=info"
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite). # IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
env_file: env_file:
- ipodderx.env # relative: Arcane resolves it inside its own container - ipx.env # relative: Arcane resolves it inside its own container
ports: ports:
- "8099:8099" # web UI - "8099:8099" # web UI
- "6881:6881/tcp" # BitTorrent peers - "6881:6881/tcp" # BitTorrent peers
- "6881:6881/udp" # DHT - "6881:6881/udp" # DHT
volumes: volumes:
- /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token - /mnt/fast/appdata/ipx:/config # config.toml, and the web token
- /mnt/user/ipodderx/:/data # state.db - /mnt/user/ipx/:/data # state.db
- /mnt/user/ipodderx/downloads:/downloads - /mnt/user/ipx/downloads:/downloads
healthcheck: healthcheck:
test: ["CMD", "ipx", "status"] test: ["CMD", "ipx", "status"]
interval: 30s interval: 30s

View File

@@ -31,7 +31,7 @@ request it forwards through the tunnel, and ipx signs that person in.
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses | | Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` | | Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied | | DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
| ipx | `/mnt/fast/appdata/ipodderx/config.toml`, `[web]` | below | | ipx | `/mnt/fast/appdata/ipx/config.toml`, `[web]` | below |
```toml ```toml
[web] [web]
@@ -51,7 +51,7 @@ on since 2026-09-19. Both can be read without the dashboard: a request to the si
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`. out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
restart ipodderx`. restart ipx`.
### What was missing ### What was missing
@@ -93,7 +93,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
send: send:
```sh ```sh
docker exec iPodderX ipx user rename <old name> <email address> docker exec iPX ipx user rename <old name> <email address>
``` ```
### Signing out ### Signing out
@@ -109,10 +109,10 @@ feeds.
### The tile in Authentik's library ### The tile in Authentik's library
Authentik's library lists Authentik's own applications, and ipodderx signs in through the one Authentik's library lists Authentik's own applications, and ipx signs in through the one
called `Cloudflare Access`, so ipodderx needs a bookmark of its own to show up there. It is called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
the iPodderX icon. Like Outline's, it has no policy bindings, so everyone in Authentik sees the the ipx logo. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
tile. Who actually gets in is still up to the Access policy. tile. Who actually gets in is still up to the Access policy.
### Check it ### Check it
@@ -222,7 +222,7 @@ echo -n 'newsecret' | ipx user passwd sam # change a password
ipx user rm sam # remove the account ipx user rm sam # remove the account
``` ```
In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones
that read a password. that read a password.
Set `auto_create_users = false` once everyone who should have an account has one. After that the Set `auto_create_users = false` once everyone who should have an account has one. After that the

181
src/db.rs
View File

@@ -2,7 +2,7 @@
//! per-feed .ipxd plists, history.dat and qmcache.dat. //! per-feed .ipxd plists, history.dat and qmcache.dat.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crate::entity::{catalogue, enclosures, entries, feeds, sessions, settings, subscriptions, users}; use crate::entity::{blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
use sea_orm::sea_query::{Expr, Func}; use sea_orm::sea_query::{Expr, Func};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
@@ -57,6 +57,8 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
schema.create_table_from_entity(sessions::Entity), schema.create_table_from_entity(sessions::Entity),
schema.create_table_from_entity(catalogue::Entity), schema.create_table_from_entity(catalogue::Entity),
schema.create_table_from_entity(settings::Entity), schema.create_table_from_entity(settings::Entity),
schema.create_table_from_entity(blocklists::Entity),
schema.create_table_from_entity(hidden::Entity),
] { ] {
orm.execute(table.if_not_exists()).await.context("creating the schema")?; orm.execute(table.if_not_exists()).await.context("creating the schema")?;
} }
@@ -156,6 +158,10 @@ pub struct Sub {
pub auto_download: Option<bool>, pub auto_download: Option<bool>,
pub allow_explicit: Option<bool>, pub allow_explicit: Option<bool>,
pub max_new_per_check: Option<i64>, pub max_new_per_check: Option<i64>,
/// Words that keep an item from being downloaded for this person: their list for every feed
/// and their list for this one together. Filled by `subscribers` only; it is kept apart from
/// the settings above (`Db::set_blocklist`).
pub blocked: Vec<String>,
} }
/// Someone who can sign in. `pass_hash` is None for an account that only ever arrives /// Someone who can sign in. `pass_hash` is None for an account that only ever arrives
@@ -200,6 +206,7 @@ impl From<subscriptions::Model> for Sub {
auto_download: s.auto_download, auto_download: s.auto_download,
allow_explicit: s.allow_explicit, allow_explicit: s.allow_explicit,
max_new_per_check: s.max_new_per_check, max_new_per_check: s.max_new_per_check,
blocked: vec![],
} }
} }
} }
@@ -620,8 +627,11 @@ impl Db {
FROM enclosures e FROM enclosures e
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
ON subs.feed_id = e.feed_id ON subs.feed_id = e.feed_id
LEFT JOIN (SELECT feed_id, guid, count(*) AS n FROM entry_state -- Someone an item is hidden from is as done with it as someone who read it.
WHERE read GROUP BY feed_id, guid) readers LEFT JOIN (SELECT feed_id, guid, count(*) AS n
FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read
UNION SELECT user_id, feed_id, guid FROM hidden) done
GROUP BY feed_id, guid) readers
ON readers.feed_id = e.feed_id AND readers.guid = e.guid ON readers.feed_id = e.feed_id AND readers.guid = e.guid
WHERE e.path IS NOT NULL WHERE e.path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM entry_state s AND NOT EXISTS (SELECT 1 FROM entry_state s
@@ -683,13 +693,17 @@ impl Db {
.await?; .await?;
// Whatever went takes everyone's read state with it, rather than leaving rows // Whatever went takes everyone's read state with it, rather than leaving rows
// pointing at an item that no longer exists. // pointing at an item that no longer exists.
for table in ["entry_state", "hidden"] {
self.exec( self.exec(
"DELETE FROM entry_state WHERE NOT EXISTS ( &format!(
"DELETE FROM {table} WHERE NOT EXISTS (
SELECT 1 FROM entries e SELECT 1 FROM entries e
WHERE e.feed_id = entry_state.feed_id AND e.guid = entry_state.guid)", WHERE e.feed_id = {table}.feed_id AND e.guid = {table}.guid)"
),
vec![], vec![],
) )
.await?; .await?;
}
Ok(n as usize) Ok(n as usize)
} }
} }
@@ -701,7 +715,9 @@ impl Db {
"SELECT count(*) AS n FROM entries e "SELECT count(*) AS n FROM entries e
LEFT JOIN entry_state s LEFT JOIN entry_state s
ON s.user_id = $2 AND s.feed_id = e.feed_id AND s.guid = e.guid ON s.user_id = $2 AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE e.feed_id = $1 AND NOT coalesce(s.read, false)", WHERE e.feed_id = $1 AND NOT coalesce(s.read, false)
AND NOT EXISTS (SELECT 1 FROM hidden h
WHERE h.user_id = $2 AND h.feed_id = e.feed_id AND h.guid = e.guid)",
vec![feed_id.into(), user_id.into()], vec![feed_id.into(), user_id.into()],
) )
.await?; .await?;
@@ -776,8 +792,16 @@ fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filte
format!( format!(
"FROM entries e "FROM entries e
LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE {scope} AND {} AND {search}", WHERE {scope} AND {} AND {search} AND {}",
filter.sql() filter.sql(),
not_hidden(&user)
)
}
/// Not hidden from this person by their block lists. `e` is entries.
fn not_hidden(user: &str) -> String {
format!(
"NOT EXISTS (SELECT 1 FROM hidden h WHERE h.user_id = {user} AND h.feed_id = e.feed_id AND h.guid = e.guid)"
) )
} }
@@ -1079,6 +1103,8 @@ impl Db {
("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?), ("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?),
("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?), ("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?),
("settings", copy_table::<settings::Entity>(&from.orm, &tx).await?), ("settings", copy_table::<settings::Entity>(&from.orm, &tx).await?),
("blocklists", copy_table::<blocklists::Entity>(&from.orm, &tx).await?),
("hidden", copy_table::<hidden::Entity>(&from.orm, &tx).await?),
]; ];
if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres { if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres {
// The copied ids came with the rows; the counters that hand out new ones start past // The copied ids came with the rows; the counters that hand out new ones start past
@@ -1166,9 +1192,12 @@ impl Db {
"SELECT coalesce(c.keywords, p.keywords) AS keywords, "SELECT coalesce(c.keywords, p.keywords) AS keywords,
coalesce(c.auto_download, p.auto_download) AS auto_download, coalesce(c.auto_download, p.auto_download) AS auto_download,
coalesce(c.allow_explicit, p.allow_explicit) AS allow_explicit, coalesce(c.allow_explicit, p.allow_explicit) AS allow_explicit,
coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check,
g.words AS global_blocked, b.words AS blocked
FROM subscriptions c FROM subscriptions c
LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = $2 LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = $2
LEFT JOIN blocklists g ON g.user_id = c.user_id AND g.feed_id = ''
LEFT JOIN blocklists b ON b.user_id = c.user_id AND b.feed_id = c.feed_id
WHERE c.feed_id = $1", WHERE c.feed_id = $1",
vec![feed_id.into(), group.map(str::to_owned).into()], vec![feed_id.into(), group.map(str::to_owned).into()],
) )
@@ -1181,6 +1210,10 @@ impl Db {
auto_download: r.try_get("", "auto_download")?, auto_download: r.try_get("", "auto_download")?,
allow_explicit: r.try_get("", "allow_explicit")?, allow_explicit: r.try_get("", "allow_explicit")?,
max_new_per_check: r.try_get("", "max_new_per_check")?, max_new_per_check: r.try_get("", "max_new_per_check")?,
blocked: [r.try_get("", "global_blocked")?, r.try_get("", "blocked")?]
.into_iter()
.flat_map(|w| keywords(w).unwrap_or_default())
.collect(),
}) })
}) })
.collect() .collect()
@@ -1217,11 +1250,13 @@ impl Db {
let r = self let r = self
.rows( .rows(
"SELECT sum(CASE WHEN st.flagged THEN 1 ELSE 0 END) AS starred, "SELECT sum(CASE WHEN st.flagged THEN 1 ELSE 0 END) AS starred,
sum(CASE WHEN st.read THEN 0 ELSE 1 END) AS unread sum(CASE WHEN st.read OR h.guid IS NOT NULL THEN 0 ELSE 1 END) AS unread
FROM enclosures e FROM enclosures e
JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2 JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2
LEFT JOIN entry_state st LEFT JOIN entry_state st
ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid
LEFT JOIN hidden h
ON h.user_id = s.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid
WHERE e.id = $1", WHERE e.id = $1",
vec![enclosure_id.into(), user_id.into()], vec![enclosure_id.into(), user_id.into()],
) )
@@ -1234,11 +1269,99 @@ impl Db {
} }
pub async fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> { pub async fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
self.exec( let new = self
.exec(
"INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", "INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
vec![user_id.into(), feed_id.into()], vec![user_id.into(), feed_id.into()],
) )
.await?; .await?;
if new > 0 {
// Their list for every feed applies to this one from the start.
self.rehide(feed_id).await?;
}
Ok(())
}
/// Someone's block list: for one feed, or with `feed_id` empty, for every feed.
pub async fn blocklist(&self, user_id: i64, feed_id: &str) -> Result<Vec<String>> {
Ok(blocklists::Entity::find_by_id((user_id, feed_id.to_owned()))
.one(&self.orm)
.await?
.and_then(|b| keywords(Some(b.words)))
.unwrap_or_default())
}
/// Replaces a block list and works out again what it hides: in that feed, or with
/// `feed_id` empty, in every feed the person reads.
pub async fn set_blocklist(&self, user_id: i64, feed_id: &str, words: &[String]) -> Result<()> {
if words.is_empty() {
blocklists::Entity::delete_by_id((user_id, feed_id.to_owned())).exec(&self.orm).await?;
} else {
self.exec(
"INSERT INTO blocklists (user_id, feed_id, words) VALUES ($1, $2, $3)
ON CONFLICT (user_id, feed_id) DO UPDATE SET words = excluded.words",
vec![user_id.into(), feed_id.into(), serde_json::to_string(words)?.into()],
)
.await?;
}
if feed_id.is_empty() {
for sub in self.subscriptions_for(user_id).await? {
self.rehide(&sub.feed_id).await?;
}
} else {
self.rehide(feed_id).await?;
}
Ok(())
}
/// Works out again which of a feed's items each subscriber's block lists hide from them.
///
/// ponytail: every item of the feed for every subscriber, each time the feed is scanned with
/// something new or a list changes. Fine at hundreds of items a feed; look only at new items
/// on a scan if a feed with thousands makes scans slow.
pub async fn rehide(&self, feed_id: &str) -> Result<()> {
let lists: Vec<(i64, Vec<String>)> = self
.rows(
"SELECT s.user_id, g.words AS global, b.words AS feed
FROM subscriptions s
LEFT JOIN blocklists g ON g.user_id = s.user_id AND g.feed_id = ''
LEFT JOIN blocklists b ON b.user_id = s.user_id AND b.feed_id = s.feed_id
WHERE s.feed_id = $1",
vec![feed_id.into()],
)
.await?
.iter()
.map(|r| {
let words = [r.try_get("", "global")?, r.try_get("", "feed")?]
.into_iter()
.flat_map(|w: Option<String>| keywords(w).unwrap_or_default())
.collect();
Ok((r.try_get("", "user_id")?, words))
})
.collect::<Result<_>>()?;
hidden::Entity::delete_many().filter(hidden::Column::FeedId.eq(feed_id)).exec(&self.orm).await?;
let lists: Vec<_> = lists.into_iter().filter(|(_, w)| !w.is_empty()).collect();
if lists.is_empty() {
return Ok(());
}
let items = entries::Entity::find().filter(entries::Column::FeedId.eq(feed_id)).all(&self.orm).await?;
let rows: Vec<hidden::ActiveModel> = items
.iter()
.flat_map(|e| {
let hay = [e.title.as_deref().unwrap_or(""), e.description.as_deref().unwrap_or("")];
lists
.iter()
.filter(move |(_, words)| crate::download::blocked(words, &hay))
.map(|(user, _)| hidden::ActiveModel {
user_id: Set(*user),
feed_id: Set(feed_id.to_owned()),
guid: Set(e.guid.clone()),
})
})
.collect();
if !rows.is_empty() {
hidden::Entity::insert_many(rows).exec_without_returning(&self.orm).await?;
}
Ok(()) Ok(())
} }
@@ -2050,6 +2173,42 @@ mod tests {
assert_eq!(listening().await, ["h", "e"]); assert_eq!(listening().await, ["h", "e"]);
} }
#[tokio::test]
async fn block_lists_hide_items_from_whoever_keeps_them() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true), (2,'b',false);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'), (2,'f');
INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
('f','x','Election night','',1),
('f','y','Baking bread','<p>No politics here, honest</p>',2),
('f','z','Gardening','',3);",
)
.await
.unwrap();
let shown = async |user| db.count_in(user, Some("f"), Filter::All, None).await.unwrap();
let words = |w: &[&str]| w.iter().map(|s| s.to_string()).collect::<Vec<_>>();
db.set_blocklist(1, "", &words(&["election"])).await.unwrap();
db.set_blocklist(1, "f", &words(&["politics"])).await.unwrap();
assert_eq!(shown(1).await, 1, "a title and a body each hide an item, both lists apply");
assert_eq!(db.unread_count(1, "f").await.unwrap(), 1, "hidden is not unread");
assert_eq!(shown(2).await, 3, "someone else's list hides nothing from you");
db.set_blocklist(1, "", &[]).await.unwrap();
assert_eq!(shown(1).await, 2, "emptying a list brings its items back");
assert!(db.blocklist(1, "").await.unwrap().is_empty());
// Someone subscribing starts with their list applied, and the scanner sees it.
db.set_blocklist(2, "", &words(&["gardening"])).await.unwrap();
db.exec_for_test("INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('g','w','Gardening',4)")
.await
.unwrap();
db.subscribe(2, "g").await.unwrap();
assert_eq!(db.count_in(2, Some("g"), Filter::All, None).await.unwrap(), 0);
assert_eq!(db.subscribers("g", None).await.unwrap()[0].blocked, ["gardening"]);
}
#[tokio::test] #[tokio::test]
async fn pending_takes_the_latest_episodes_first() { async fn pending_takes_the_latest_episodes_first() {
// A cap of 3 must mean the three newest, not the three recorded first. // A cap of 3 must mean the three newest, not the three recorded first.

View File

@@ -305,6 +305,25 @@ pub fn matches_keywords(keywords: &[String], haystacks: &[&str]) -> bool {
}) })
} }
/// Whether any of these words or phrases appears, as whole words, in the haystacks. Whole words,
/// unlike `matches_keywords`, because a block hides things: "ai" should not hide everything that
/// "said" something. Case and punctuation are ignored, so "A.I." is not caught by "ai", but
/// "Trump's" is by "trump".
pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
// Letters and digits only, each run of anything else one space, padded so a phrase matches
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
let norm = |s: &str| {
let mut out = String::from(" ");
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
out.push_str(&w.to_lowercase());
out.push(' ');
}
out
};
let hay = norm(&haystacks.join(" "));
words.iter().map(|w| norm(w)).any(|w| w.len() > 1 && hay.contains(&w))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -404,4 +423,16 @@ mod tests {
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match"); assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything"); assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
} }
#[test]
fn blocking_matches_whole_words_and_phrases() {
let words = vec!["AI".to_string(), "new york".to_string()];
assert!(blocked(&words, &["What AI means now"]));
assert!(blocked(&words, &["<p>ai, again</p>"]), "markup and punctuation are not words");
assert!(!blocked(&words, &["He said so", "Portrait"]), "not inside another word");
assert!(blocked(&words, &["Live from", "New York."]), "a phrase spans runs of space");
assert!(!blocked(&words, &["New Yorkshire"]));
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
assert!(!blocked(&[], &["anything"]));
}
} }

View File

@@ -228,6 +228,46 @@ pub mod entry_state {
owned_by_user!(); owned_by_user!();
} }
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
/// every feed they read.
pub mod blocklists {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "blocklists")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
/// JSON array of strings.
#[sea_orm(column_type = "Text")]
pub words: String,
}
owned_by_user!();
}
/// The items someone's block lists hide from them, worked out whenever a list or the feed
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
pub mod hidden {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "hidden")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
}
owned_by_user!();
}
pub mod sessions { pub mod sessions {
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;

View File

@@ -1254,6 +1254,10 @@ async fn scan_one(
} }
} }
if scan.new_entries > 0 {
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
}
let budget = policy.budget; let budget = policy.budget;
if policy.auto_download && budget > 0 { if policy.auto_download && budget > 0 {
let cfg = ctx.cfg(); let cfg = ctx.cfg();
@@ -1458,15 +1462,10 @@ fn reject(
entry.description.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or(""),
categories.as_str(), categories.as_str(),
]; ];
// One file serves everyone subscribed, so an item is wanted if it is wanted by let text = [entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or("")];
// anyone: any one person's keyword set matching is enough. if !policy.wanted(&haystacks, &text) {
let wanted_by_someone = policy.keyword_sets.is_empty() // Worded for whichever filter could have let it through, the keywords if none blocked it.
|| policy return Some(if policy.wanted(&haystacks, &[]) { "blocked word" } else { "no keyword match" });
.keyword_sets
.iter()
.any(|set| download::matches_keywords(set, &haystacks));
if !wanted_by_someone {
return Some("no keyword match");
} }
None None
} }
@@ -1480,11 +1479,28 @@ fn reject(
pub struct Policy { pub struct Policy {
pub auto_download: bool, pub auto_download: bool,
pub allow_explicit: bool, pub allow_explicit: bool,
/// Empty means take everything. Otherwise one set per subscriber who filters. /// What each subscriber fetching the feed wants.
pub keyword_sets: Vec<Vec<String>>, pub wants: Vec<Want>,
pub budget: usize, pub budget: usize,
} }
/// One person's filters: an item is theirs if it matches their keywords (all of it, with none)
/// and none of their blocked words.
pub struct Want {
pub keywords: Vec<String>,
pub blocked: Vec<String>,
}
impl Policy {
/// One file serves everyone subscribed, so an item is wanted if anyone wants it. Keywords
/// look at `haystacks`, blocked words at `text`, the item's title and body only.
fn wanted(&self, haystacks: &[&str], text: &[&str]) -> bool {
self.wants.iter().any(|w| {
download::matches_keywords(&w.keywords, haystacks) && !download::blocked(&w.blocked, text)
})
}
}
async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Policy> { async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Policy> {
let global = ctx.cfg().general.max_new_per_check; let global = ctx.cfg().general.max_new_per_check;
Ok(merge_policy(&ctx.db.subscribers(id, feed_cfg.group.as_deref()).await?, feed_cfg, global)) Ok(merge_policy(&ctx.db.subscribers(id, feed_cfg.group.as_deref()).await?, feed_cfg, global))
@@ -1497,11 +1513,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
return Policy { return Policy {
auto_download: feed_cfg.auto_download, auto_download: feed_cfg.auto_download,
allow_explicit: feed_cfg.allow_explicit, allow_explicit: feed_cfg.allow_explicit,
keyword_sets: if feed_cfg.keywords.is_empty() { wants: vec![Want { keywords: feed_cfg.keywords.clone(), blocked: vec![] }],
vec![]
} else {
vec![feed_cfg.keywords.clone()]
},
budget: cap(feed_cfg.max_new_per_check), budget: cap(feed_cfg.max_new_per_check),
}; };
} }
@@ -1509,7 +1521,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
let mut policy = Policy { let mut policy = Policy {
auto_download: false, auto_download: false,
allow_explicit: false, allow_explicit: false,
keyword_sets: vec![], wants: vec![],
budget: 0, budget: 0,
}; };
for sub in subs { for sub in subs {
@@ -1521,12 +1533,10 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
policy.budget = policy policy.budget = policy
.budget .budget
.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check))); .max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
let kw = sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()); policy.wants.push(Want {
if kw.is_empty() { keywords: sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()),
// Somebody takes everything, so no filter can apply to the shared copy. blocked: sub.blocked.clone(),
return Policy { keyword_sets: vec![], ..policy }; });
}
policy.keyword_sets.push(kw);
} }
policy policy
} }
@@ -1795,6 +1805,7 @@ mod tests {
auto_download: auto, auto_download: auto,
allow_explicit: None, allow_explicit: None,
max_new_per_check: max, max_new_per_check: max,
blocked: vec![],
} }
} }
@@ -1804,7 +1815,7 @@ mod tests {
let p = merge_policy(&[], &feed(), 3); let p = merge_policy(&[], &feed(), 3);
assert!(p.auto_download); assert!(p.auto_download);
assert_eq!(p.budget, 3); assert_eq!(p.budget, 3);
assert!(p.keyword_sets.is_empty()); assert!(p.wanted(&["anything"], &[]));
// Two filters: an item wanted by either of them is fetched, since one file serves // Two filters: an item wanted by either of them is fetched, since one file serves
// both. The larger per-scan cap wins for the same reason. // both. The larger per-scan cap wins for the same reason.
@@ -1813,12 +1824,21 @@ mod tests {
&feed(), &feed(),
3, 3,
); );
assert_eq!(p.keyword_sets.len(), 2); assert!(p.wanted(&["rust"], &[]) && p.wanted(&["sqlite"], &[]));
assert!(!p.wanted(&["python"], &[]));
assert_eq!(p.budget, 9); assert_eq!(p.budget, 9);
// One person taking everything removes the filter for the shared copy. // One person taking everything removes the filter for the shared copy.
let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3); let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3);
assert!(p.keyword_sets.is_empty()); assert!(p.wanted(&["python"], &[]));
// A blocked word keeps an item from being fetched for that person, not for the rest.
let blocking = |w: &str| db::Sub { blocked: vec![w.into()], ..sub(None, None, None) };
let p = merge_policy(&[blocking("politics")], &feed(), 3);
assert!(!p.wanted(&[], &["Politics today"]));
assert!(p.wanted(&[], &["Cooking today"]));
let p = merge_policy(&[blocking("politics"), sub(None, None, None)], &feed(), 3);
assert!(p.wanted(&[], &["Politics today"]), "someone else still wants it");
// Everyone has auto-download off: nothing is fetched automatically. // Everyone has auto-download off: nothing is fetched automatically.
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(false), None)], &feed(), 3); let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(false), None)], &feed(), 3);

View File

@@ -67,6 +67,7 @@ pub fn router(state: WebState) -> Router {
.route("/login", get(login_page)) .route("/login", get(login_page))
.route("/api/login", post(login)) .route("/api/login", post(login))
.route("/icon.png", get(icon)) .route("/icon.png", get(icon))
.route("/logo.svg", get(logo))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.route("/favicon.png", get(favicon)) .route("/favicon.png", get(favicon))
.route("/apple-touch-icon.png", get(touch_icon)) .route("/apple-touch-icon.png", get(touch_icon))
@@ -332,29 +333,40 @@ async fn me(
let url = state.ctx.cfg().web.sign_out_url.clone(); let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url); let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default(); let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
Json(serde_json::json!({ Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode, "name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
"blocked": blocked,
})) }))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct MePatch { struct MePatch {
theme: String, theme: Option<String>,
mode: String, mode: Option<String>,
/// Words that hide an item in every feed you read.
blocked: Option<Vec<String>>,
} }
/// Saves the theme to the account, so it follows the person rather than the browser. /// Saves the theme to the account, so it follows the person rather than the browser, and the
/// block list for every feed.
async fn patch_me( async fn patch_me(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
Json(body): Json<MePatch>, Json(body): Json<MePatch>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
if body.theme.is_some() || body.mode.is_some() {
let (theme, mode) = (body.theme.unwrap_or_default(), body.mode.unwrap_or_default());
// The page's script knows the themes; this only makes sure what is kept is safe to write // The page's script knows the themes; this only makes sure what is kept is safe to write
// into the page's <html> tag, which is where index() puts it. // into the page's <html> tag, which is where index() puts it.
if !theme_ok(&body.theme, &body.mode) { if !theme_ok(&theme, &mode) {
return Err(ApiError::bad_request("not a theme")); return Err(ApiError::bad_request("not a theme"));
} }
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?; state.ctx.db.set_theme(user.id, &theme, &mode).await?;
}
if let Some(words) = body.blocked {
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
}
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -541,8 +553,16 @@ fn script(js: &'static str) -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js) ([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
} }
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The /// The logo the pages show, served once rather than inlined into each. The sign-in page shows
/// sign-in page shows it, so it sits outside the auth layer with /login. /// it, so it sits outside the auth layer with /login.
async fn logo() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
include_str!("../web/logo.svg"),
)
}
/// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it.
async fn icon() -> impl IntoResponse { async fn icon() -> impl IntoResponse {
( (
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")], [(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
@@ -550,9 +570,8 @@ async fn icon() -> impl IntoResponse {
) )
} }
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not /// web/logo.svg at 128px. /favicon.ico is the same PNG, for a browser that asks for that on its
/// square can be passed over. /favicon.ico is the same PNG, for a browser that asks for that /// own; behind the auth layer it answered 401, and the tab stayed blank.
/// on its own; behind the auth layer it answered 401, and the tab stayed blank.
async fn favicon() -> impl IntoResponse { async fn favicon() -> impl IntoResponse {
( (
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")], [(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
@@ -560,8 +579,8 @@ async fn favicon() -> impl IntoResponse {
) )
} }
/// For an iPhone's home screen, which paints a transparent icon's background black, so this /// web/logo.svg at 180px, for an iPhone's home screen. It is opaque edge to edge, as iOS paints
/// one is on white. /// a transparent icon's background black and cuts its own corners.
async fn touch_icon() -> impl IntoResponse { async fn touch_icon() -> impl IntoResponse {
( (
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")], [(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
@@ -639,6 +658,8 @@ struct FeedRow {
category: Option<String>, category: Option<String>,
feed_category: Option<String>, feed_category: Option<String>,
keywords: Vec<String>, keywords: Vec<String>,
/// Your words that hide an item of this feed, beside your list for every feed.
blocked: Vec<String>,
allow_explicit: bool, allow_explicit: bool,
auto_download: bool, auto_download: bool,
max_new_per_check: Option<usize>, max_new_per_check: Option<usize>,
@@ -715,6 +736,7 @@ async fn feeds(
.clone() .clone()
.or_else(|| up.and_then(|u| u.keywords.clone())) .or_else(|| up.and_then(|u| u.keywords.clone()))
.unwrap_or_else(|| feed.keywords.clone()), .unwrap_or_else(|| feed.keywords.clone()),
blocked: state.ctx.db.blocklist(user.id, id).await?,
allow_explicit: mine allow_explicit: mine
.allow_explicit .allow_explicit
.or(up.and_then(|u| u.allow_explicit)) .or(up.and_then(|u| u.allow_explicit))
@@ -1272,6 +1294,7 @@ struct FeedPatch {
#[serde(default, deserialize_with = "double_option")] #[serde(default, deserialize_with = "double_option")]
category: Option<Option<String>>, category: Option<Option<String>>,
keywords: Option<Vec<String>>, keywords: Option<Vec<String>>,
blocked: Option<Vec<String>>,
allow_explicit: Option<bool>, allow_explicit: Option<bool>,
auto_download: Option<bool>, auto_download: Option<bool>,
#[serde(default, deserialize_with = "double_option")] #[serde(default, deserialize_with = "double_option")]
@@ -1279,6 +1302,21 @@ struct FeedPatch {
pinned: Option<bool>, pinned: Option<bool>,
} }
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
/// looked for in every item of every feed the list covers.
fn clean_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
let mut out: Vec<String> = Vec::new();
for w in words.iter().map(|w| w.trim()).filter(|w| !w.is_empty()) {
if !out.iter().any(|o| o.eq_ignore_ascii_case(w)) {
out.push(w.to_owned());
}
}
if out.len() > 500 || out.iter().any(|w| w.len() > 200) {
return Err(ApiError::bad_request("a block list holds up to 500 words of up to 200 characters"));
}
Ok(out)
}
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error> fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where where
T: Deserialize<'de>, T: Deserialize<'de>,
@@ -1327,6 +1365,9 @@ async fn patch_feed(
if touched { if touched {
state.ctx.db.set_subscription(user.id, &mine).await?; state.ctx.db.set_subscription(user.id, &mine).await?;
} }
if let Some(v) = body.blocked.clone() {
state.ctx.db.set_blocklist(user.id, &id, &clean_words(v)?).await?;
}
} }
// The rest describes the feed itself -- where its files land, its address, when it is // The rest describes the feed itself -- where its files land, its address, when it is

View File

@@ -73,6 +73,8 @@ const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) { for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
const p = themes[name]; const p = themes[name];
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
const sheen = [[+sm[1], +sm[2], +sm[3]], +sm[4]];
const worst = {}; const worst = {};
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]]) for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) { for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
@@ -81,8 +83,10 @@ for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100)); const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
g = over(c, a * Math.max(0, 1 - d), g); g = over(c, a * Math.max(0, 1 - d), g);
} }
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it. // The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it,
for (const [gn, gc] of [['the wash', g], ['a panel', over(rgb(p.panel), .7, g)]]) // and a panel's top-left corner under its sheen at full strength.
const panel = over(rgb(p.panel), .7, g);
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]])
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) { for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`; const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
if (!(k in worst) || r < worst[k]) worst[k] = r; if (!(k in worst) || r < worst[k]) worst[k] = r;

View File

@@ -558,10 +558,10 @@ test('a second person has their own feeds and their own read state', async ({ br
const ctx = await browser.newContext(); const ctx = await browser.newContext();
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto('/login'); await page.goto('/login');
// The sign-in page shows the icon, so it has to load before anyone has signed in. // The sign-in page shows the logo, so it has to load before anyone has signed in.
const icon = await page.request.get('/icon.png'); const logo = await page.request.get('/logo.svg');
expect(icon.status()).toBe(200); expect(logo.status()).toBe(200);
expect(icon.headers()['content-type']).toBe('image/png'); expect(logo.headers()['content-type']).toBe('image/svg+xml');
await page.locator('#name').fill('sam'); await page.locator('#name').fill('sam');
await page.locator('#pw').fill('sampassword'); await page.locator('#pw').fill('sampassword');
await page.locator('button[type=submit]').click(); await page.locator('button[type=submit]').click();
@@ -1179,7 +1179,7 @@ test('a file not yet downloaded has its icon in line with the rest of its row',
}); });
test('the favicon is the logo, square, from both pages', async ({ page }) => { test('the favicon is the logo, square, from both pages', async ({ page }) => {
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/favicon.png'); await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
// A browser asks for /favicon.ico on its own, signed in or not. // A browser asks for /favicon.ico on its own, signed in or not.
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) { for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
const r = await page.request.get(path, { headers: { cookie: '' } }); const r = await page.request.get(path, { headers: { cookie: '' } });
@@ -1211,14 +1211,15 @@ test.describe('touch gestures on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol. // Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
async function drag(page, from, to) { async function drag(page, from, to, lift = true) {
const cdp = await page.context().newCDPSession(page); const cdp = await page.context().newCDPSession(page);
const steps = 8; const steps = 8;
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] }); await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
for (let i = 1; i <= steps; i++) for (let i = 1; i <= steps; i++)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] }); x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); if (lift) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
return cdp;
} }
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => { test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
@@ -1231,6 +1232,17 @@ test.describe('touch gestures on a phone', () => {
const shown = page.locator('#detail .dt'); const shown = page.locator('#detail .dt');
await expect(shown).toHaveText(titles[0]); await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 230, y: 410 }); // too short: stays (#49)
await expect(shown).toHaveText(titles[0]);
await expect(page.locator('#detail')).not.toHaveAttribute('style', /translate/); // springs back
// Held partway: the next item is beside the reader, not the list under it (#52), and
// let go short of a swipe, it goes again.
const held = await drag(page, { x: 300, y: 400 }, { x: 230, y: 405 }, false);
await expect(page.locator('#dpeek.next .dt')).toHaveText(titles[1]);
await expect(page.locator('#detail')).toHaveCSS('opacity', '1');
await held.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect(page.locator('#dpeek')).toHaveCount(0);
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
await expect(shown).toHaveText(titles[1]); await expect(shown).toHaveText(titles[1]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
@@ -1341,3 +1353,33 @@ test('"check every feed" checks only the feeds of the person asking', async ({ b
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id); for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
await ctx.close(); await ctx.close();
}); });
test('words in Settings hide the items that mention them', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toBeVisible({ timeout: 20_000 });
await page.locator('#prefs').click();
await page.locator('#sblock').fill('notes for the second'); // a phrase, in the show notes
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#sblock').press('Tab');
expect((await saved).status()).toBe(204);
await page.locator('#modal .cardx').click();
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toHaveCount(0);
await expect(page.locator('.ep', { hasText: 'First Episode' })).toBeVisible();
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
});
test('Share hands the feed, the item and the file to the share sheet', async ({ page }) => {
await page.evaluate(() => { window.shared = []; navigator.share = async d => { window.shared.push(d); }; });
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await page.locator('#content .acts [data-a="share"]').click();
await page.locator('.ep', { hasText: 'Second Episode' }).click();
await page.locator('.encbox [data-a="share"]').first().click();
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
]);
});

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>iPodderX admin</title> <title>iPX admin</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" data-src="app.css"> <link rel="stylesheet" data-src="app.css">
@@ -12,8 +12,8 @@
<body class="adminpage"> <body class="adminpage">
<!-- The server sends this page, and its script, to admins only. --> <!-- The server sends this page, and its script, to admins only. -->
<header id="topbar"> <header id="topbar">
<a class="btn ico" href="/" title="Back to iPodderX" aria-label="Back to iPodderX" data-icon="left"></a> <a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
<img class="logo" src="/icon.png" alt="" width="26"> <img class="logo" src="/logo.svg" alt="" width="26">
<h1>Admin</h1> <h1>Admin</h1>
<span class="grow"></span> <span class="grow"></span>
<nav class="tabs" id="atabs"> <nav class="tabs" id="atabs">

View File

@@ -423,7 +423,9 @@
--bad:#ff6961; --bad:#ff6961;
--shadow:0 10px 36px rgba(0,0,0,.45); --shadow:0 10px 36px rgba(0,0,0,.45);
--glass-edge:rgba(255,255,255,.10); --glass-edge:rgba(255,255,255,.10);
--glass-hi:rgba(255,255,255,.14); --glass-hi:rgba(255,255,255,.26);
--glass-lo:rgba(255,255,255,.10);
--glass-sheen:rgba(255,255,255,.07);
--wash1:rgba(64,120,255,.30); --wash1:rgba(64,120,255,.30);
--wash2:rgba(175,82,222,.24); --wash2:rgba(175,82,222,.24);
--wash3:rgba(48,176,199,.20); --wash3:rgba(48,176,199,.20);
@@ -446,6 +448,8 @@
--shadow:0 10px 36px rgba(30,50,90,.16); --shadow:0 10px 36px rgba(30,50,90,.16);
--glass-edge:rgba(255,255,255,.65); --glass-edge:rgba(255,255,255,.65);
--glass-hi:rgba(255,255,255,.9); --glass-hi:rgba(255,255,255,.9);
--glass-lo:rgba(255,255,255,.45);
--glass-sheen:rgba(255,255,255,.5);
--wash1:rgba(64,120,255,.22); --wash1:rgba(64,120,255,.22);
--wash2:rgba(175,82,222,.16); --wash2:rgba(175,82,222,.16);
--wash3:rgba(48,176,199,.18); --wash3:rgba(48,176,199,.18);
@@ -491,7 +495,7 @@ a{color:var(--accent)}
} }
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px} .brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
.brand .logo{ .brand .logo{
width:30px;height:30px;flex:none;object-fit:contain; width:30px;height:30px;flex:none;border-radius:7px;
} }
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1} .brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
.iconbtn{ .iconbtn{
@@ -500,11 +504,12 @@ a{color:var(--accent)}
} }
.iconbtn:hover{background:var(--raise);color:var(--fg)} .iconbtn:hover{background:var(--raise);color:var(--fg)}
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */ /* One toolbar across the window, as the original had: grouped buttons, search on the right. */
/* The display's own intrusions -- the home indicator along the bottom, the notch at one side in /* The display's own intrusions -- the Dynamic Island and status bar along the top (#54), the
landscape. viewport-fit=cover hands the page the whole screen, which is what a standalone home indicator along the bottom, the notch at one side in landscape. viewport-fit=cover hands the page the whole screen, which is what a standalone
shell and an iOS home-screen app both give it, so the bars along the edges pay for them in shell and an iOS home-screen app both give it, so the bars along the edges pay for them in
padding. A browser with its own chrome reports nought and nothing moves. */ padding. A browser with its own chrome reports nought and nothing moves. */
:root{ :root{
--safe-t:env(safe-area-inset-top,0px);
--safe-b:env(safe-area-inset-bottom,0px); --safe-b:env(safe-area-inset-bottom,0px);
--safe-l:env(safe-area-inset-left,0px); --safe-l:env(safe-area-inset-left,0px);
--safe-r:env(safe-area-inset-right,0px); --safe-r:env(safe-area-inset-right,0px);
@@ -514,7 +519,7 @@ a{color:var(--accent)}
/* Longhand, and it has to stay longhand: the minifier runs a calc() in a padding shorthand /* Longhand, and it has to stay longhand: the minifier runs a calc() in a padding shorthand
into the value after it -- `calc(12px + var(--safe-r))7px` -- and the browser then throws into the value after it -- `calc(12px + var(--safe-r))7px` -- and the browser then throws
the whole declaration away, leaving the bar with no padding at all. */ the whole declaration away, leaving the bar with no padding at all. */
padding-top:7px;padding-bottom:7px; padding-top:calc(7px + var(--safe-t));padding-bottom:7px;
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r)); padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
background:var(--panel);border-bottom:1px solid var(--line); background:var(--panel);border-bottom:1px solid var(--line);
} }
@@ -674,7 +679,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
/* Three panes, as the original had: feeds beside, items above, the item below. */ /* Three panes, as the original had: feeds beside, items above, the item below. */
#split{ #split{
display:grid;flex:1;min-height:0; display:grid;flex:1;min-height:0;overflow:hidden; /* the reader sliding on a swipe */
grid-template-columns:minmax(0,1fr) 270px; grid-template-columns:minmax(0,1fr) 270px;
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr; grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
grid-template-areas:"list files" "grab grab" "detail detail"; grid-template-areas:"list files" "grab grab" "detail detail";
@@ -763,7 +768,7 @@ kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:
body.adminpage{display:block;overflow:auto} body.adminpage{display:block;overflow:auto}
body.adminpage #topbar{position:sticky;top:0;z-index:5} body.adminpage #topbar{position:sticky;top:0;z-index:5}
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650} body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
body.adminpage #topbar .logo{border-radius:5px} body.adminpage #topbar .logo{border-radius:6px}
body.adminpage #topbar > a.btn{text-decoration:none} body.adminpage #topbar > a.btn{text-decoration:none}
body.adminpage #admin{max-width:780px;margin:0 auto} body.adminpage #admin{max-width:780px;margin:0 auto}
body.adminpage #admin h2{font-size:18px;margin:0 0 12px} body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
@@ -962,7 +967,7 @@ body:has(#player.on) #status{padding-bottom:3px}
@media (max-width:820px){ @media (max-width:820px){
#shell{grid-template-columns:1fr} #shell{grid-template-columns:1fr}
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)} #sidebar{position:fixed;inset:0 auto 0 0;padding-top:var(--safe-t);width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
#sidebar.open{transform:none} #sidebar.open{transform:none}
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41} #scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
#player{ #player{
@@ -973,7 +978,10 @@ body:has(#player.on) #status{padding-bottom:3px}
/* The feed list is reachable whether or not anything is playing. */ /* The feed list is reachable whether or not anything is playing. */
#burger{display:grid} #burger{display:grid}
#topbar{gap:6px;padding:6px 8px} #topbar{
gap:6px;padding-top:calc(6px + var(--safe-t));padding-bottom:6px;
padding-left:calc(8px + var(--safe-l));padding-right:calc(8px + var(--safe-r));
}
#topbar .grow,.tgroup.item{display:none} #topbar .grow,.tgroup.item{display:none}
#topbar #epSearch{width:auto;flex:1;min-width:0} #topbar #epSearch{width:auto;flex:1;min-width:0}
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px} .tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
@@ -986,9 +994,21 @@ body:has(#player.on) #status{padding-bottom:3px}
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none} #files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto} .ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
#grab{display:none} #grab{display:none}
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px} #detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t))}
body.reading #detail{display:block} body.reading #detail{display:block}
#dback{display:inline-block;margin-bottom:10px} #dback{display:inline-block;margin-bottom:10px}
/* Beside the reader while it is swiped (gestures.ts): the item next to it, with a strip of the
page's own background between them, so the list under the reader never shows (#52). */
#dpeek{position:fixed;top:0;bottom:0;left:0;width:calc(100% + 16px);z-index:38;background:var(--bg);pointer-events:none}
#dpeek>div{width:calc(100% - 16px);height:100%;overflow:hidden;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t));background:var(--panel)}
#dpeek.next>div{margin-left:16px}
#dpeek .encbox{margin:0 0 10px}
#dpeek.end{display:flex;align-items:center;padding-left:28px;color:var(--faint);font-size:13px}
#dpeek.end p{max-width:4.5em;margin:0}
/* Going back to the list from the first item: the list waits under a dimmer that lifts as the
reader, shadowed along its edge, is drawn off it. */
#dpeek.dim{width:100%;z-index:37;background:#000}
#detail.popping{box-shadow:-12px 0 32px rgba(0,0,0,.28)}
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px} #content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
#content>.fhead{padding-top:12px} #content>.fhead{padding-top:12px}
@@ -1020,6 +1040,90 @@ body:has(#player.on) #status{padding-bottom:3px}
#logbox .tg{display:none} #logbox .tg{display:none}
} }
/* ---------- A phone, drawn the way iOS draws its own apps (#53) ---------- */
/* Filled shapes rather than outlines, corners that grow rounder as the shape grows and nest inside
each other, 44px to tap and 17px to read. The outlines stay where they are what makes a control
visible at all: the high-contrast theme, and anyone who has asked the system for more contrast.
--edge is the one switch for that. */
@media (max-width:820px){
:root{--edge:transparent}
:root[data-theme="contrast"]{--edge:var(--line)}
/* Glass rules its panels off with an edge of its own; the light along them (--glass-rim) stays. */
:root[data-theme="glass"],:root[data-theme="glass"][data-mode="light"]{--glass-edge:transparent}
}
@media (max-width:820px) and (prefers-contrast:more){
:root{--edge:var(--line)}
}
@media (max-width:820px){
/* The bars run into the page instead of being ruled off from it. */
#topbar{background:var(--bg);border-bottom-color:var(--edge);gap:8px}
#status{background:var(--bg);border-top-color:var(--edge)}
#player{border-top-color:var(--edge)}
.sidefoot{border-top-color:var(--edge)}
/* Buttons: filled and round. Hover is reset because Safari keeps it on whatever was last
tapped, which left an accent outline on it. */
.btn,.sidetools button,.sidefoot button,.tgroup,.toast,kbd,#logbox{border-color:var(--edge)}
.btn:hover,.sidetools button:hover,.sidefoot button:hover{border-color:var(--edge)}
.btn,.sidetools button,.sidefoot button{border-radius:999px;min-height:40px;padding-left:14px;padding-right:14px}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.btn.ico,#dback{min-width:40px;height:40px;padding:0 10px;display:inline-grid;place-items:center}
.btn.tiny{min-height:0;padding:2px 9px}
.iconbtn{width:40px;height:40px;border-radius:999px;background:var(--panel2)}
.tgroup{border-radius:999px}
.tgroup button+button,.tgroup button+a{border-left-color:var(--edge)}
.tgroup button,.tgroup a{min-width:36px;height:40px;padding:0 8px}
/* Text boxes filled, and at 16px or more: Safari zooms the page in on one any smaller. */
input[type=search],input[type=text],input[type=password],input[type=number],select{
font-size:17px;background:var(--panel2);border-color:var(--edge);border-radius:10px;padding:9px 12px;
}
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 14px}
input:focus,select:focus{border-color:var(--accent)}
/* The feed's name as the page's large title, the way Podcasts heads a show. */
.fhead h2,.fhead.slim h2{font-size:26px;font-weight:700;letter-spacing:-.01em;line-height:1.2}
.fhead .sub{font-size:15px}
.acts{gap:8px}
.acts .btn{min-width:44px;height:44px;padding:0 13px;justify-content:center;font-size:15px}
/* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */
.tabs{flex:1 1 100%;border-radius:10px;padding:2px}
.tabs button,.tabs a{flex:1;text-align:center;padding:7px 4px;border-radius:8px;font-size:14px}
.tabs button.on,.tabs a.on{box-shadow:0 1px 3px rgba(0,0,0,.22)}
/* Rows: taller, larger type, and a hairline between them that starts where the text does. */
.ep{padding:10px 8px;border-radius:10px;min-height:44px}
.ep.sel{border-color:var(--edge)}
.ep:hover:not(.sel){background-color:transparent}
.ep .t{font-size:17px}
.ep .line,.ep .date{font-size:15px}
.ep+.ep{background-image:linear-gradient(var(--line),var(--line));background-size:calc(100% - 64px) 1px;background-position:64px 0;background-repeat:no-repeat}
.ep.sel,.ep.sel+.ep{background-image:none}
.feed{padding:9px 10px;min-height:44px;border-radius:10px}
.places{border-bottom-color:var(--edge);padding-bottom:10px;margin-bottom:10px}
.feed .txt b{font-size:17px}
.feed .txt small{font-size:13px}
/* The reader. */
.dt{font-size:26px;font-weight:700;line-height:1.2;letter-spacing:-.01em;margin-bottom:8px}
.dmeta{font-size:15px;gap:10px}
.encbox{border-color:var(--edge);border-radius:14px;padding:8px 8px 8px 14px}
.dbody{font-size:17px;line-height:1.55}
/* Dialogs are sheets, up from the bottom. */
#modal{place-items:end stretch;padding:0}
.card,.card.wide{
width:100%;max-height:calc(100dvh - var(--safe-t) - 12px);border-color:var(--edge);border-radius:20px 20px 0 0;
padding:20px 16px calc(20px + var(--safe-b));animation:sheet .3s cubic-bezier(.2,.8,.2,1);
}
.card h3{font-size:22px}
.field label,.field .hint{font-size:13px}
.check{font-size:17px}
.toast{border-radius:14px}
}
@keyframes sheet{from{transform:translateY(100%)}}
/* ---------- Classic: the 2004 Mac app ---------- */ /* ---------- Classic: the 2004 Mac app ---------- */
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue /* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
for whatever is selected, red unread badges, a striped table and Lucida Grande. */ for whatever is selected, red unread badges, a striped table and Lucida Grande. */
@@ -1082,7 +1186,10 @@ body:has(#player.on) #status{padding-bottom:3px}
/* ---------- Glass: after Apple's Liquid Glass ---------- */ /* ---------- Glass: after Apple's Liquid Glass ---------- */
/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it. /* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it.
Apple's glass also bends light at its edges; that needs an SVG displacement filter that only Apple's glass also bends light at its edges; that needs an SVG displacement filter that only
Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43). */ Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43).
What is kept is the light: the rim catches it at the top-left edges and, fainter, the far ones,
as it passes through (#51). */
:root[data-theme="glass"]{--glass-rim:inset 1px 1px 0 var(--glass-hi),inset -1px -1px 0 var(--glass-lo)}
:root[data-theme="glass"] body{ :root[data-theme="glass"] body{
font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif; font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif;
background: background:
@@ -1097,13 +1204,17 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="glass"] #status, :root[data-theme="glass"] #status,
:root[data-theme="glass"] #files, :root[data-theme="glass"] #files,
:root[data-theme="glass"] #detail, :root[data-theme="glass"] #detail,
:root[data-theme="glass"] #dpeek>div,
:root[data-theme="glass"] .card, :root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast{ :root[data-theme="glass"] .toast{
background:color-mix(in srgb,var(--panel) 70%,transparent); /* A sheen from the top-left corner, where the light comes from. It is a background layer rather
than a pseudo-element because the detail pane, the file list and the dialogs scroll, and an
absolutely placed layer inside a scroller scrolls away with the content. */
background:linear-gradient(135deg,var(--glass-sheen),transparent 45%),color-mix(in srgb,var(--panel) 70%,transparent);
-webkit-backdrop-filter:blur(24px) saturate(180%); -webkit-backdrop-filter:blur(24px) saturate(180%);
backdrop-filter:blur(24px) saturate(180%); backdrop-filter:blur(24px) saturate(180%);
border-color:var(--glass-edge); border-color:var(--glass-edge);
box-shadow:inset 0 1px 0 var(--glass-hi); box-shadow:var(--glass-rim);
} }
:root[data-theme="glass"] .toolbar, :root[data-theme="glass"] .toolbar,
:root[data-theme="glass"] .ephead{ :root[data-theme="glass"] .ephead{
@@ -1113,8 +1224,7 @@ body:has(#player.on) #status{padding-bottom:3px}
backdrop-filter:blur(20px) saturate(180%); backdrop-filter:blur(20px) saturate(180%);
} }
:root[data-theme="glass"] .card, :root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast{box-shadow:inset 0 1px 0 var(--glass-hi),var(--shadow)} :root[data-theme="glass"] .toast{box-shadow:var(--glass-rim),var(--shadow)}
:root[data-theme="glass"] .card{border-radius:20px}
:root[data-theme="glass"] .toast{border-radius:14px} :root[data-theme="glass"] .toast{border-radius:14px}
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)} :root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */ /* A narrow screen slides the feed list over the page, so it keeps its shadow. */
@@ -1126,12 +1236,25 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="glass"] .tgroup, :root[data-theme="glass"] .tgroup,
:root[data-theme="glass"] .tabs{ :root[data-theme="glass"] .tabs{
background:color-mix(in srgb,var(--panel2) 60%,transparent); background:color-mix(in srgb,var(--panel2) 60%,transparent);
border-color:var(--glass-edge);border-radius:12px;box-shadow:inset 0 1px 0 var(--glass-hi); border-color:var(--glass-edge);box-shadow:var(--glass-rim);
} }
/* Rounder than the other themes; a phone has shapes of its own (#53), which these would outrank. */
@media (min-width:821px){
:root[data-theme="glass"] .card{border-radius:20px}
:root[data-theme="glass"] .tgroup,
:root[data-theme="glass"] .tabs{border-radius:12px}
:root[data-theme="glass"] .btn, :root[data-theme="glass"] .btn,
:root[data-theme="glass"] .sidetools button, :root[data-theme="glass"] .sidetools button,
:root[data-theme="glass"] .sidefoot button{border-radius:10px} :root[data-theme="glass"] .sidefoot button{border-radius:10px}
}
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)} :root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
/* On a phone the rim all round a small capsule or bar read as the outline #53 took away, so the
light only catches the top edge there, and the bars at the top and bottom have none. */
@media (max-width:820px){
:root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)}
:root[data-theme="glass"] #topbar,
:root[data-theme="glass"] #status{box-shadow:none}
}
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */ /* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){ @media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
:root[data-theme="glass"] body{background:var(--bg)} :root[data-theme="glass"] body{background:var(--bg)}
@@ -1141,6 +1264,7 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="glass"] #status, :root[data-theme="glass"] #status,
:root[data-theme="glass"] #files, :root[data-theme="glass"] #files,
:root[data-theme="glass"] #detail, :root[data-theme="glass"] #detail,
:root[data-theme="glass"] #dpeek>div,
:root[data-theme="glass"] .card, :root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast, :root[data-theme="glass"] .toast,
:root[data-theme="glass"] .tgroup, :root[data-theme="glass"] .tgroup,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -72,6 +72,11 @@ export function buildPage(name, { minify = true } = {}) {
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`); let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/, out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`); () => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
out = out.replace(/(href|src)="\/(favicon\.png|apple-touch-icon\.png|logo\.svg)"/g,
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
if (!minify) return { html: out, js, script }; if (!minify) return { html: out, js, script };
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true }); const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'Error'); const bad = (r.errors || []).filter(e => e.level === 'Error');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>iPodderX</title> <title>iPX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" data-src="app.css"> <link rel="stylesheet" data-src="app.css">
@@ -34,7 +34,7 @@
<div id="shell"> <div id="shell">
<aside id="sidebar"> <aside id="sidebar">
<div class="brand"> <div class="brand">
<img class="logo" src="/icon.png" alt="iPodderX" title="The original iPodderX icon, 2004"><h1>iPodderX</h1> <img class="logo" src="/logo.svg" alt="iPX"><h1>iPX</h1>
</div> </div>
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div> <div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div id="feedlist"></div> <div id="feedlist"></div>

View File

@@ -3,7 +3,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>Sign in — iPodderX</title> <title>Sign in — iPX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style> <style>
@@ -55,9 +55,9 @@ form{
width:min(360px,100%);background:var(--panel);border:1px solid var(--line); width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
border-radius:14px;padding:22px;box-shadow:var(--shadow); border-radius:14px;padding:22px;box-shadow:var(--shadow);
} }
/* The one place the 2004 icon is shown at the size it was drawn for. */ /* The logo is a square app icon, so it gets an app icon's corners. */
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px} .brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
.brand img{width:96px;height:auto} .brand img{width:72px;height:72px;border-radius:16px}
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em} h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px} label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
input{ input{
@@ -74,7 +74,7 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style> </style>
<form id="f"> <form id="f">
<div class="brand"><img src="/icon.png" alt=""><h1>iPodderX</h1></div> <div class="brand"><img src="/logo.svg" alt=""><h1>iPX</h1></div>
<label for="name">Name</label> <label for="name">Name</label>
<input id="name" name="name" autocomplete="username" autofocus required> <input id="name" name="name" autocomplete="username" autofocus required>
<label for="pw">Password</label> <label for="pw">Password</label>

19
web/logo-dark.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- logo.svg's dark appearance: the same layers in the same places, only recoloured, as the
guidelines ask, so the icon reads as itself in either. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1d4468"/>
<stop offset="1" stop-color="#0c2238"/>
</linearGradient>
</defs>
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
<g id="scale" fill="#8fc2ea">
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
</g>
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff9f2e"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1003 B

22
web/logo.svg Normal file
View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- The iPodderX radio's screen, reduced to its tuning scale and needle, laid out as Apple's
app icon guidelines ask: square and opaque (the system cuts the corners, and paints
transparency black), one background and flat foreground layers with hard edges, no
highlights or shadows of its own (the system adds them), nothing thin enough to vanish
at 32px. Each <g> is a layer, for splitting out into Icon Composer. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#dff0fb"/>
<stop offset="1" stop-color="#9fccef"/>
</linearGradient>
</defs>
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
<g id="scale" fill="#2f6aa0">
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
</g>
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#f7931e"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -234,6 +234,11 @@ async function prefsModal(){
</div> </div>
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import <span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
subscribes you to every feed in one.</span></div> subscribes you to every feed in one.</span></div>
<div class="field"><label for="sblock">Hide items mentioning</label>
<input type="text" id="sblock" value="${esc((S.me?.blocked||[]).join(', '))}">
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
one in its title or text is hidden from you and not downloaded for you. Each feed's
settings can add more.</span></div>
<div class="field"><label>Feeds are checked every</label> <div class="field"><label>Feeds are checked every</label>
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own. <span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div> ${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
@@ -242,8 +247,18 @@ async function prefsModal(){
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal; $('#gopml').onclick=opmlModal;
$('#sblock').onchange=async e=>{
const blocked=splitWords(e.target.value);
try{
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
if(S.me) S.me.blocked=blocked;
toast('Saved'); await loadFeeds(true); loadEntries();
}catch(err){ toast(err.message,true); }
};
} }
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
function settingsModal(f, newUrl?: string){ function settingsModal(f, newUrl?: string){
const isGroup = S.feeds.some(c=>c.group===f.id); const isGroup = S.feeds.some(c=>c.group===f.id);
openModal(`<h3>${esc(f.title||f.id)}</h3> openModal(`<h3>${esc(f.title||f.id)}</h3>
@@ -257,6 +272,11 @@ function settingsModal(f, newUrl?: string){
<div class="field"><label>Keywords</label> <div class="field"><label>Keywords</label>
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}"> <input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
<span class="hint">Comma separated. Empty takes everything.</span></div> <span class="hint">Comma separated. Empty takes everything.</span></div>
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
<span class="hint">Comma separated words or phrases. An item with one in its title or text
is hidden from you and not downloaded for you, as well as those your Settings hide
everywhere.</span></div>`}
<div class="field"><label>Max new downloads per scan</label> <div class="field"><label>Max new downloads per scan</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}"> <input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for <span class="hint">Blank follows the global default (${globalMax}). The rest wait for
@@ -294,9 +314,10 @@ function settingsModal(f, newUrl?: string){
const max=$('#smax').value; const max=$('#smax').value;
try{ try{
const patch: Record<string, unknown>={ const patch: Record<string, unknown>={
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean), keywords:splitWords($('#skw').value),
max_new_per_check:max===''?null:Number(max), max_new_per_check:max===''?null:Number(max),
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked}; auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
// The shared half is an admin's to change, and the API refuses it from anyone else. // The shared half is an admin's to change, and the API refuses it from anyone else.
if(S.me&&S.me.admin){ if(S.me&&S.me.admin){
patch.url=$('#surl').value.trim(); patch.url=$('#surl').value.trim();

View File

@@ -31,6 +31,7 @@ function renderFeed(){
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button> <button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button> <button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button> <button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button> <button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button> <button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div> </div>
@@ -120,6 +121,7 @@ function renderGroup(f,kids){
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button> <button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button> <button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button> <button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button> <button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button> <button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div> </div>
@@ -166,6 +168,7 @@ async function feedAction(a,f){
}catch(e){ toast(e.message,true); } }catch(e){ toast(e.message,true); }
} }
if(a==='settings') settingsModal(f); if(a==='settings') settingsModal(f);
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
if(a==='dl') downloadLatestModal(f); if(a==='dl') downloadLatestModal(f);
} }
/// All Subscriptions' own buttons: a feed's, across every feed you read. /// All Subscriptions' own buttons: a feed's, across every feed you read.

View File

@@ -5,7 +5,12 @@
// the document because the panes are rebuilt every time a feed renders. // the document because the panes are rebuilt every time a feed renders.
const PULL = 70; // px down, from the list's top, that counts as a pull const PULL = 70; // px down, from the list's top, that counts as a pull
const SWIPE = 60; // px across that counts as a swipe // A quarter of the reader's width across, and never less than 100px: at a flat 60px a thumb
// scrolling slightly on the diagonal jumped to the next item by accident (issue #49).
const swipeMin = () => Math.max(100, ($('#detail')?.clientWidth || 0) / 4);
const GAP = 16; // px of page between two items side by side, so they read as two sheets
const narrow = () => !!window.matchMedia?.('(max-width:820px)')?.matches;
const still = () => !!window.matchMedia?.('(prefers-reduced-motion:reduce)')?.matches;
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null; let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
@@ -46,9 +51,15 @@ document.addEventListener('touchstart', ev => {
const t = ev.touches[0], target = ev.target as Element; const t = ev.touches[0], target = ev.target as Element;
const detail = target.closest?.('#detail'), list = target.closest?.('#list'); const detail = target.closest?.('#detail'), list = target.closest?.('#list');
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise. // Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
if(detail && S.sel && !ownsSwipe(target)) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0}; if(detail && S.sel && !ownsSwipe(target)){
touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
// Safari takes a swipe from the screen's left edge as Back, which leaves the page halfway
// through going back to the list. Kept narrower than the reader's padding, so a tap on the
// back button is never swallowed with it.
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
}
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0}; else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
}, {passive: true}); }, {passive: false});
document.addEventListener('touchmove', ev => { document.addEventListener('touchmove', ev => {
if(!touch) return; if(!touch) return;
@@ -60,11 +71,74 @@ document.addEventListener('touchmove', ev => {
pullShow(touch.dy); pullShow(touch.dy);
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){ }else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
touch = null; // scrolling the text, not swiping; the first few px slide(0); touch = null; // scrolling the text, not swiping; the first few px
// decide nothing, being mostly jitter // decide nothing, being mostly jitter
}else if(Math.abs(touch.dx) > 10){
// The reader follows the finger, so it is plain before letting go whether this will move on;
// with nothing that way it only gives a little.
const i = S.entries.findIndex(x => x.guid === S.sel);
const end = touch.dx < 0 && i >= S.entries.length - 1;
slide(end ? touch.dx / 3 : touch.dx, 0);
} }
}, {passive: false}); }, {passive: false});
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
/// to, a note that there are no more, or, going back to the list, a dimmer over the list. The
/// reader sits over the list there, and moving it alone showed the list through the gap it left
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
/// copy of the reader's markup repeats the reader's ids.
function beside(dx: number){
const i = S.entries.findIndex(x => x.guid === S.sel), e = S.entries[i + (dx < 0 ? 1 : -1)];
const kind = dx < 0 ? (e ? 'next' : 'end') : (e ? 'prev' : 'dim');
let p = $('#dpeek');
if(!p){
p = document.createElement('div'); p.id = 'dpeek'; p.inert = true; p.setAttribute('aria-hidden', 'true');
document.body.append(p);
}
if(p.dataset.key !== kind + (e?.guid || '')){
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : kind === 'end' ? '<p>No more items</p>' : '';
}
return p;
}
/// Moves the reader across by dx over ms, or at once (when following a finger), and what lies
/// beside it with it.
function slide(dx: number, ms = 160){
const d = $('#detail'); if(!d) return;
dx = Math.round(dx); // whole pixels, or the seam between the reader and its neighbour blurs
const w = d.clientWidth || 1, tr = ms ? `transform ${ms}ms ease-out, opacity ${ms}ms ease-out` : 'none';
d.style.transition = tr;
d.style.transform = dx ? `translateX(${dx}px)` : '';
// A wider screen has the reader as a pane beside the list, with nothing under it to show.
if(!narrow()){ d.style.opacity = dx ? String(Math.max(.4, 1 - Math.abs(dx) / w)) : ''; return; }
const p = dx ? beside(dx) : $('#dpeek');
d.classList.toggle('popping', !!dx && p?.className === 'dim');
if(!p) return;
p.style.transition = tr;
if(p.className === 'dim') p.style.opacity = String(.35 * (1 - Math.abs(dx) / w));
else p.style.transform = `translateX(${dx + (p.className === 'prev' ? -w - GAP : w)}px)`;
// Gone once the reader is back, unless another drag has already begun.
if(!dx){ if(ms) setTimeout(() => { if(!touch) $('#dpeek')?.remove(); }, ms); else p.remove(); }
}
/// The reader carries on the way the finger went, from wherever it was let go, and the item
/// beside it becomes the reader in its place. Timers, not transitionend, because with reduced
/// motion the stylesheet turns transitions off and transitionend never comes.
function slideOn(dx: number, go: () => void){
const d = $('#detail'), w = d?.clientWidth || 0, dir = Math.sign(dx);
if(!narrow()){
// A pane beside the list: off one side, and the next item in from the other.
slide(dir * w);
setTimeout(() => { go(); slide(-dir * w, 0); requestAnimationFrame(() => requestAnimationFrame(() => slide(0))); }, 160);
return;
}
const to = dir * (w + (beside(dx).className === 'dim' ? 0 : GAP));
const ms = still() ? 0 : Math.round(Math.max(120, 250 * (1 - Math.abs(dx) / (w || 1))));
slide(to, ms);
setTimeout(() => { go(); slide(0, 0); }, ms);
}
document.addEventListener('touchend', () => { document.addEventListener('touchend', () => {
const g = touch; touch = null; const g = touch; touch = null;
if(!g) return; if(!g) return;
@@ -74,9 +148,11 @@ document.addEventListener('touchend', () => {
return; return;
} }
// Across, mostly sideways, and not a slow drag while selecting text. // Across, mostly sideways, and not a slow drag while selecting text.
if(Math.abs(g.dx) < SWIPE || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800) return;
const i = S.entries.findIndex(x => x.guid === S.sel); const i = S.entries.findIndex(x => x.guid === S.sel);
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; } if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800
if(i > 0) stepEntry(-1); else showDetail(null); || (g.dx < 0 && i >= S.entries.length - 1)) return slide(0);
if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1));
if(i > 0) return slideOn(g.dx, () => stepEntry(-1));
slideOn(g.dx, () => showDetail(null));
}); });
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; }); document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });

View File

@@ -230,6 +230,19 @@ function showDetail(e){
} }
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone, // Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
// where the files sit over the text, a box saying "No files" only pushed the text down. // where the files sit over the text, a box saying "No files" only pushed the text down.
const encs=e.enclosures.map(encBox).join('');
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
box.innerHTML=detailHtml(e);
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
$('#dback').onclick=()=>showDetail(null);
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
syncPlayButtons();
}
/// What the reader shows for an item. Its own function because a phone also draws the next or
/// previous item beside the reader while it is swiped (gestures.ts).
function detailHtml(e){
const encs=e.enclosures.map(encBox).join(''); const encs=e.enclosures.map(encBox).join('');
// A phone has no room for the files pane, so the files go above the text there instead. // A phone has no room for the files pane, so the files go above the text there instead.
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone // Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
@@ -237,7 +250,8 @@ function showDetail(e){
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches; const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
const f=S.feeds.find(x=>x.id===e.feed_id); const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=` // description was sanitized server-side with ammonia before it ever reached here
return `
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button> <button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3> <h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta"> <div class="dmeta">
@@ -249,16 +263,11 @@ function showDetail(e){
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}" <button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button> aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer" ${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''} title="Open the original" aria-label="Open the original">${ICON.open}</a>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}
</div> </div>
${narrow?encs:''} ${narrow?encs:''}
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`; <div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
// description was sanitized server-side with ammonia before it ever reached here
$('#dback').onclick=()=>showDetail(null);
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
syncPlayButtons();
} }
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it. /// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
@@ -273,6 +282,8 @@ function encBox(x){
: 'Delete file'; : 'Delete file';
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`; const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`; const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
// The publisher's address, not this server's copy, which only someone signed in here can open.
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
if(x.path && !isPlayable(x)){ if(x.path && !isPlayable(x)){
// On disk, but not audio or video: view it, keep it, or remove it -- no player. // On disk, but not audio or video: view it, keep it, or remove it -- no player.
return `<div class="encbox"> return `<div class="encbox">
@@ -280,6 +291,7 @@ function encBox(x){
<span class="meta" style="flex:1">${size}</span> <span class="meta" style="flex:1">${size}</span>
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a> <a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
${saveBtn} ${saveBtn}
${shareBtn}
${delBtn} ${delBtn}
</div>`; </div>`;
} }
@@ -291,6 +303,7 @@ function encBox(x){
<span class="meta" style="flex:1">${size}</span> <span class="meta" style="flex:1">${size}</span>
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button> <button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
${saveBtn} ${saveBtn}
${shareBtn}
${delBtn} ${delBtn}
</div>`; </div>`;
} }
@@ -303,6 +316,7 @@ function encBox(x){
<span class="meta" style="flex:1">${size}</span> <span class="meta" style="flex:1">${size}</span>
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''} ${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''} ${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
${shareBtn}
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button> <button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
</div>`; </div>`;
} }
@@ -314,6 +328,7 @@ async function epAction(a: string, e, el, encId?: number){
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`; const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{ try{
if(a==='play') play(e, encId!=null ? enc : undefined); if(a==='play') play(e, encId!=null ? enc : undefined);
if(a==='share') await share(e.title||'', encId!=null ? enc.url : e.link, el);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); } if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ await setRead(e,!e.read); redraw(); } if(a==='read'){ await setRead(e,!e.read); redraw(); }
if(a==='get'){ if(a==='get'){

View File

@@ -10,6 +10,7 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag. // the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`; const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
const ICON={ const ICON={
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
@@ -92,6 +93,27 @@ async function copyText(text,btn){
} }
} }
/// The system's share sheet where there is one (phones, Safari, Edge), the clipboard elsewhere.
/// Asks first when the address looks like it carries your own access to a paid or private
/// feed: a Patreon feed's token, or a key, token or password in it, which would hand whoever
/// gets it your subscription (issue #48).
///
/// ponytail: a guess from the address. A private feed whose key has another name is shared
/// without asking; add its pattern here when one turns up.
async function share(title: string, url: string, btn?){
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
&& !confirm('This address looks like it includes your own access to the feed, and anyone you '
+'send it to could use it as you.\n\nShare it anyway?')) return;
if(navigator.share){
// Cancelling the sheet rejects too, and is not a failure worth a word.
try{ await navigator.share({title, url}); }
catch(e){ if(e.name!=='AbortError') toast(e.message,true); }
return;
}
await copyText(url,btn);
toast('Link copied');
}
function toast(msg: string, bad?: boolean){ function toast(msg: string, bad?: boolean){
const t=document.createElement('div'); const t=document.createElement('div');
t.className='toast'+(bad?' bad':''); t.textContent=msg; t.className='toast'+(bad?' bad':''); t.textContent=msg;