14 Commits

Author SHA1 Message Date
c957db7f61 ipx 1.2.0, ipx-directory 1.1.1, ipx-web 1.0.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:25:18 +00:00
4e0b5eed5d Failures and events go out without a private feed's key (#238)
A fetch that failed logged reqwest's words, which name the whole address: Patreon's auth key
went to Loki on 2026-10-15, and, since scan events go to every open page, to everyone's
browser too. #225 had taken queries out of the traces' address fields only.

Every event is redacted where it is emitted, for the log, the live stream and a terminal
alike: address fields lose their query and password, and so does any address in an error's
words (feed::redact_text). The artwork proxy's warnings and the directory's failed checks
the same. The error kept on the feed, shown to its own subscribers, keeps the detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:18:08 +00:00
a9991545f9 Tests: two people on one file, each with their own DeadAir version, place and playlist
The DeadAir tests named for each person's clock used one account. Now three play one file at
once (DeadAir at two pauses, and the original) and each keeps their own place; and two fetch
the same /media/{id}/deadair.m3u8 at once and each gets their own playlist, no-store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:04:47 +00:00
60007d4e6a Page type-check: strict throughout, a typed $, null checks (#236)
$<T> returns the element type it is given and assumes the element is there: 122 lookups take
their type from the tag their id is on in the markup. A handler reads its target through a
cast, since a typed element will not take a handler whose event names a narrower target.
strictNullChecks found no bug the page had; its 94 places were values the code already handles
in ways the type-check could not follow (data- attributes the page writes, a map entry set the
line before, Share drawn only for an item with a link), each now said so.

isPlayable stays a plain boolean: as a type guard its false would have meant "no file", and it
is also false for a file that is not audio or video.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:50:33 +00:00
1471b9b9d0 ipx-web without curl, which CVE-2026-92392 affects in caddy:2's 8.22.0 (#237)
Nothing in the image ran it: Caddy is Go, and there has been no healthcheck since #218.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:45:19 +00:00
ec2b9e87b8 Page type-check: noImplicitAny, every parameter and variable typed (#235)
290 places the type-check could not see into, mostly parameters, now typed with web/src/types.ts
(feeds, items, files, accounts, log lines, the directory's rows and items). Two changes run
differently in form only: the seek and volume inputs divide Number(value) where they divided
the string, and esc's table has a name. Null checks are #236.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:57:05 +00:00
af6eae9baf Page type-check: types for what the API sends, and for S (#235)
web/src/types.ts mirrors FeedRow, EntryRow, EncRow, /api/me and PopularRow; types only, so
swc strips it and it is not in build.mjs's list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:52:22 +00:00
78704e40f2 Page type-check: strict's flags that cost nothing, and catch variables as unknown (#235)
why(e) gives a caught error's message; each catch read e.message as any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:51:41 +00:00
f20660a7d5 Unit tests for the page's own functions (#234)
tests/page-unit.js loads the built index.html script into the stub DOM page-smoke.js uses and
calls its helpers directly with node:test: escaping, times and sizes, file kinds, an untitled
item's name, the directory's sorts, filter words, DeadAir's playlist address. Top-level consts
are not properties of the vm context, so each check evaluates its expression in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:47:15 +00:00
a5e64e520d Refresh buttons turn from the press to the end of their scan; quick checks keep the count (#232, #233)
The buttons followed the feeds being checked, so they stopped between one feed and the next;
7ecbe82 held them for 600 ms over each gap. Now pressing one starts it, and scan_done stops
it only when it is the end of the scan asked for: scan_done carries `only` and `forced`, so a
scheduled scan finishing first does not stop a button whose scan is queued behind it. An
error event, a job that failed without a scan_done, stops them all.

A row's spinner stood in for its unread count from the moment its feed was checked; most
checks take milliseconds, so each count blinked as the scan went by. It now waits 300 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:00:50 +00:00
7ecbe8293f The refresh buttons keep turning between one feed and the next (#231)
A scan checks its feeds one at a time; in the gap between two the body class came off, the
icon snapped back upright and the spin restarted, many times a second with fetches ahead.
The class now comes off only after 600 ms with nothing being checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:49:55 +00:00
8d7a30d1f9 Faster scans and directory rounds, and fetches named by feed (#227, #228, #229, #230)
From the slow traces of 2026-10-13: the directory checked a round's 25 feeds one after
another, so two libsyn feeds timing out at 30 s made a round 70 s; a forced scan looked up
62 site icons in turn, 22 s of 62; mjg59.dreamwidth.org takes the connection and never
answers, so it ran the full 30 s in every scan it was in; and since #225 strips queries,
every YouTube fetch span read the same address, outside any span naming its feed.

The directory now checks five at a time (SQLite stays on one connection, so its writes
queue). A forced scan looks up the icon in the fetch-ahead task, which already runs six
deep, and hands it to scan_one with the body. Both feed clients give up after 15 s of
silence (no successful fetch took over 8 s in a day of traces); FEED_TIMEOUT stays 30 s
for a feed still sending. The directory's client had no connect timeout; it has ipx's 10 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:27:19 +00:00
cece114d98 Grafana: a panel querying Loki and Prometheus both is Mixed, so Downloads works (#226)
Each panel took its first query's data source, Loki for Downloads, whose at-once and torrent
lines are Prometheus's; Grafana runs another data source's query only on a Mixed panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:09:32 +00:00
f00adf5da5 ipx and ipx-directory 1.1.1-dev, ipx-web 1.0.2-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:25:30 +00:00
30 changed files with 1008 additions and 401 deletions

View File

@@ -151,13 +151,19 @@ After any edit to it:
```sh ```sh
npx tsc -p . npx tsc -p .
node tests/page-smoke.js node tests/page-smoke.js
node --test tests/page-unit.js
``` ```
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a
type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes
with them, and a value that may be null has to be checked before use. `$<T>` returns the element
type it is given (`$<HTMLInputElement>('#nurl')`) and assumes the element is there; one that may
not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second
builds the page as shipped and runs its script against a stub DOM, checking every selector it builds the page as shipped and runs its script against a stub DOM, checking every selector it
wires at load actually exists. That check exists because a patch once anchored on a deleted wires at load actually exists. That check exists because a patch once anchored on a deleted
function, `String.replace` silently matched nothing, and the whole UI died with a function, `String.replace` silently matched nothing, and the whole UI died with a
`ReferenceError` while every server-side test passed. `ReferenceError` while every server-side test passed. The third loads the same built script and
calls its helpers directly (#234): a pure function added to the page gets its check there.
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping. (`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
@@ -168,6 +174,7 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src npx tsc -p . # type-checks web/src
node tests/page-smoke.js node tests/page-smoke.js
node --test tests/page-unit.js # the page's own functions, called directly, in a second
node tests/native-bridge.js # the page hands playback to a native shell node tests/native-bridge.js # the page hands playback to a native shell
node tests/contrast.js # every theme's palette against WCAG AA node tests/contrast.js # every theme's palette against WCAG AA
npx playwright test # 66 browser tests against a real daemon on fixture feeds npx playwright test # 66 browser tests against a real daemon on fixture feeds

5
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "1.1.0" version = "1.2.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",
@@ -1856,12 +1856,13 @@ dependencies = [
[[package]] [[package]]
name = "ipx-directory" name = "ipx-directory"
version = "1.1.0" version = "1.1.1"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",
"axum", "axum",
"clap", "clap",
"futures-util",
"ipx-feed", "ipx-feed",
"mimalloc", "mimalloc",
"opentelemetry", "opentelemetry",

View File

@@ -8,6 +8,9 @@ COPY web ./web
RUN node web/build.mjs /dist RUN node web/build.mjs /dist
FROM caddy:2 FROM caddy:2
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a
# curl flaw such as CVE-2026-92392 has nothing to reach in a container facing the internet (#237).
RUN apk del --no-cache curl
COPY web/Caddyfile /etc/caddy/Caddyfile COPY web/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /dist /srv COPY --from=build /dist /srv
EXPOSE 8080 EXPOSE 8080

View File

@@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.1.1] - 2026-10-15
### Changed
- The directory checks five feeds at a time, so a publisher that does not answer no longer holds up the rest of its round (#227).
- A feed's server that takes 10 s to connect, or sends nothing for 15 s, is given up on (#230).
### Security
- A failed feed check is logged without its address's query or password (#238).
## [1.1.0] - 2026-10-13 ## [1.1.0] - 2026-10-13
### Added ### Added
@@ -34,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205). - `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213). - Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...main [unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...main
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0 [1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0 [1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx-directory" name = "ipx-directory"
version = "1.1.0" version = "1.1.1"
edition.workspace = true edition.workspace = true
[[bin]] [[bin]]
@@ -12,6 +12,7 @@ ammonia.workspace = true
anyhow.workspace = true anyhow.workspace = true
axum.workspace = true axum.workspace = true
clap.workspace = true clap.workspace = true
futures-util.workspace = true
ipx-feed.workspace = true ipx-feed.workspace = true
opentelemetry.workspace = true opentelemetry.workspace = true
opentelemetry-otlp.workspace = true opentelemetry-otlp.workspace = true

View File

@@ -134,6 +134,8 @@ impl Directory {
pub fn new(store: Store, tokens: Vec<Token>) -> Result<Shared, reqwest::Error> { pub fn new(store: Store, tokens: Vec<Token>) -> Result<Shared, reqwest::Error> {
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop. // No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(ipx_feed::FEED_SILENCE)
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
.build()?; .build()?;
@@ -175,7 +177,7 @@ impl Directory {
Err(e) => Refreshed::Failed(format!("{e:#}")), Err(e) => Refreshed::Failed(format!("{e:#}")),
}; };
if let Refreshed::Failed(e) = &got { if let Refreshed::Failed(e) = &got {
tracing::info!(feed = %due.id, error = %e, "feed check failed"); tracing::info!(feed = %due.id, error = %ipx_feed::redact_text(e), "feed check failed");
} }
// ponytail: a feed that moved for good is still asked for at its old address, which // ponytail: a feed that moved for good is still asked for at its old address, which
// redirects; follow the move when a feed's id can change without losing its counts. // redirects; follow the move when a feed's id can change without losing its counts.
@@ -193,12 +195,17 @@ pub async fn refresher(dir: Shared) {
let round = tracing::info_span!("refresh", feeds = due.len()); let round = tracing::info_span!("refresh", feeds = due.len());
tracing::Instrument::instrument( tracing::Instrument::instrument(
async { async {
for d in &due { // A few at a time: in turn, two publishers timing out held a round
// for a minute and more (#227).
futures_util::StreamExt::for_each_concurrent(futures_util::stream::iter(&due), 5, |d| {
let check = tracing::info_span!("feed", feed = %d.id); let check = tracing::info_span!("feed", feed = %d.id);
if let Err(e) = tracing::Instrument::instrument(dir.refresh(d), check).await { async {
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check"); if let Err(e) = tracing::Instrument::instrument(dir.refresh(d), check).await {
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check");
}
} }
} })
.await;
// One line a round, for the dashboard's refresher panel (#210); failures have their own. // One line a round, for the dashboard's refresher panel (#210); failures have their own.
tracing::info!(checked = due.len(), "feeds checked"); tracing::info!(checked = due.len(), "feeds checked");
}, },

View File

@@ -61,6 +61,12 @@ pub enum Fetched {
/// downloads, which are not, have no such limit. /// downloads, which are not, have no such limit.
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// The longest a feed's server may send nothing, for the clients that fetch feeds to set: one
/// that took the connection and never answered ran the whole 30 s, every scan it was in (#230).
/// No feed that answered took over 8 s in a day of traces; one that keeps sending, however
/// slowly, still has until FEED_TIMEOUT.
pub const FEED_SILENCE: std::time::Duration = std::time::Duration::from_secs(15);
/// Feeds fetched and the bytes of their bodies, since the process started, for /metrics to set /// Feeds fetched and the bytes of their bodies, since the process started, for /metrics to set
/// beside memory (#224): a body is read whole, so a night of large feeds is a night of large /// beside memory (#224): a body is read whole, so a night of large feeds is a night of large
/// allocations. /// allocations.
@@ -90,6 +96,21 @@ pub fn redact(url: &str) -> String {
} }
} }
/// Text with each address in it redacted, for what is logged and sent to every open page: an
/// error from reqwest names the whole address it fetched, a private feed's key with it (#238).
pub fn redact_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find("http://").into_iter().chain(rest.find("https://")).min() {
out.push_str(&rest[..i]);
let end = rest[i..].find(|c: char| c.is_whitespace() || "()<>\"'".contains(c)).map_or(rest.len(), |n| i + n);
out.push_str(&redact(&rest[i..end]));
rest = &rest[end..];
}
out.push_str(rest);
out
}
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))] #[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
pub async fn fetch( pub async fn fetch(
client: &reqwest::Client, client: &reqwest::Client,
@@ -1196,6 +1217,13 @@ mod tests {
assert_eq!(super::redact("https://me:pw@example.com/feed.xml#top"), "https://example.com/feed.xml"); assert_eq!(super::redact("https://me:pw@example.com/feed.xml#top"), "https://example.com/feed.xml");
assert_eq!(super::redact("https://example.com/feed.xml"), "https://example.com/feed.xml"); assert_eq!(super::redact("https://example.com/feed.xml"), "https://example.com/feed.xml");
assert_eq!(super::redact("not a url?token=SECRET"), "not a url"); assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
// As reqwest words a failure, and with two addresses and none.
assert_eq!(
super::redact_text("connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"),
"connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out"
);
assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g");
assert_eq!(super::redact_text("HTTP 404 Not Found"), "HTTP 404 Not Found");
} }
#[test] #[test]

View File

@@ -12,6 +12,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.2.0] - 2026-10-15
### Added
- `scan_done` on the event stream says which feed or folder the scan was limited to and whether someone asked for it (`only`, `forced`), so a page can tell the end of its own scan from a scheduled one's (#233).
### Changed
- Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228).
- A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230).
- A scan's trace names the feed each fetch is for (#229).
### Security
- A failure's words, and the addresses in an event, go to the log and to every open page without their query or password, where a private feed keeps its key: a failed fetch of a Patreon feed logged its `auth` key (#238).
## [1.1.0] - 2026-10-13 ## [1.1.0] - 2026-10-13
### Added ### Added
@@ -816,7 +832,8 @@ 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/ipx/compare/v1.1.0...main [unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...main
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0 [1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0 [1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0 [0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "1.1.0" version = "1.2.0"
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]

View File

@@ -36,7 +36,15 @@ pub enum Event {
DeadairDone { feed: String, enclosure: i64, saved_s: i64 }, DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
Reaped { path: String, bytes: u64 }, Reaped { path: String, bytes: u64 },
/// Terminal: a client that asked for work stops reading here. /// Terminal: a client that asked for work stops reading here.
ScanDone { feeds: usize }, ScanDone {
feeds: usize,
/// The feed or folder the scan was limited to, and whether someone asked for it: a page
/// stops the button they pressed at the end of their scan, not at a scheduled one's (#233).
#[serde(default)]
only: Option<String>,
#[serde(default)]
forced: bool,
},
ReapDone { files: usize, bytes: u64 }, ReapDone { files: usize, bytes: u64 },
Status { feeds: usize, pending: i64, downloaded: i64 }, Status { feeds: usize, pending: i64, downloaded: i64 },
Error { msg: String }, Error { msg: String },
@@ -82,7 +90,7 @@ impl Event {
Event::FeedStart { feed } => format!("{feed}: checking"), Event::FeedStart { feed } => format!("{feed}: checking"),
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"), Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"), Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"), Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
}) })
} }
} }
@@ -136,6 +144,7 @@ impl Emitter {
} }
pub fn emit(&self, e: Event) { pub fn emit(&self, e: Event) {
let e = e.redacted();
// Also log it. Scans and downloads travel as events, not tracing calls, so // Also log it. Scans and downloads travel as events, not tracing calls, so
// without this the log view shows only startup and HTTP lines and none of the // without this the log view shows only startup and HTTP lines and none of the
// work the daemon is actually doing. Progress goes to debug: it fires on every // work the daemon is actually doing. Progress goes to debug: it fires on every
@@ -159,6 +168,29 @@ impl Emitter {
} }
} }
impl Event {
/// The event as it may go out: to the log, to every open page whoever's feed it is, to a
/// terminal. Addresses lose their query and password, where a private feed keeps its key, and
/// so do the ones in an error's words (#238).
fn redacted(mut self) -> Self {
use crate::feed::{redact, redact_text};
match &mut self {
Event::DownloadError { url, msg, .. } => {
*url = redact(url);
*msg = redact_text(msg);
}
Event::TorrentDeferred { url, .. } => *url = redact(url),
Event::FeedMoved { from, to, .. } => {
*from = redact(from);
*to = redact(to);
}
Event::FeedError { msg, .. } | Event::Error { msg } => *msg = redact_text(msg),
_ => {}
}
self
}
}
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as /// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and, /// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex. /// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
@@ -378,6 +410,30 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed"); assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
} }
#[test]
fn what_goes_out_names_no_private_feeds_key() {
let (tx, mut rx) = broadcast::channel(8);
let out = Emitter::socket(tx, false);
let key = "auth=SECRET";
out.emit(Event::FeedError {
feed: "f".into(),
msg: format!("connecting: error sending request for url (https://x/rss?{key}&show=1): operation timed out"),
});
out.emit(Event::DownloadError {
feed: "f".into(),
enclosure: 1,
url: format!("https://cdn/ep.mp3?{key}"),
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
});
out.emit(Event::FeedMoved { feed: "f".into(), from: format!("https://x/rss?{key}"), to: format!("https://y/rss?{key}") });
// What every open page is sent, whoever's feed it is (#238).
for _ in 0..3 {
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
assert!(!sent.contains("SECRET"), "{sent}");
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
}
}
#[test] #[test]
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() { fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() }; let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
@@ -434,7 +490,7 @@ mod tests {
#[test] #[test]
fn only_completion_events_end_a_client_session() { fn only_completion_events_end_a_client_session() {
assert!(Event::ScanDone { feeds: 1 }.is_terminal()); assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal()); assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
assert!(!Event::FeedDone { assert!(!Event::FeedDone {
feed: "a".into(), feed: "a".into(),

View File

@@ -355,6 +355,7 @@ async fn main() -> Result<()> {
feed_client: reqwest::Client::builder() feed_client: reqwest::Client::builder()
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
.connect_timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(feed::FEED_SILENCE)
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.build()?, .build()?,
out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() }, out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() },
@@ -1260,18 +1261,33 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
const AHEAD: usize = 6; const AHEAD: usize = 6;
let mut bodies = futures_util::StreamExt::buffered( let mut bodies = futures_util::StreamExt::buffered(
futures_util::stream::iter(due.iter().map(|(sub, state)| { futures_util::stream::iter(due.iter().map(|(sub, state)| {
let (client, feed_cfg) = (ctx.feed_client.clone(), sub.cfg.clone()); let (client, web, feed_cfg) = (ctx.feed_client.clone(), ctx.client.clone(), sub.cfg.clone());
let (etag, modified) = (state.etag.clone(), state.last_modified.clone()); let (etag, modified) = (state.etag.clone(), state.last_modified.clone());
let early = !feed::is_patreon_creator(&feed_cfg.url); let early = !feed::is_patreon_creator(&feed_cfg.url);
// Named for its feed: the fetch's own span has only the address, without its query
// since #225, which for every YouTube channel is the same (#229).
let span = tracing::info_span!("fetch_ahead", feed = %sub.id);
tokio::spawn(tracing::Instrument::instrument( tokio::spawn(tracing::Instrument::instrument(
async move { async move {
if !early { if !early {
return None; return None;
} }
let _fetching = metrics::busy(&metrics::STATS.feeds_fetching); let _fetching = metrics::busy(&metrics::STATS.feeds_fetching);
Some(feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref()).await) let got = feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref()).await;
// A forced scan looks for the site's icon of each feed naming no artwork: here,
// a few feeds at a time, not each in its turn, 22 s of a 62 s scan (#228).
let icon = match &got {
Ok((feed::Fetched::Body { bytes, .. }, _)) if force => match feed::parse(bytes) {
Ok(feed::ParsedFeed { image: None, site: Some(site), .. }) => {
Some(feed::site_icon(&web, &site).await.unwrap_or_default())
}
_ => None,
},
_ => None,
};
Some((got, icon))
}, },
tracing::Span::current(), span,
)) ))
})), })),
AHEAD, AHEAD,
@@ -1345,7 +1361,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
} }
} }
} }
ctx.out.emit(Event::ScanDone { feeds: scanned }); ctx.out.emit(Event::ScanDone { feeds: scanned, only: only.map(str::to_owned), forced: force });
// What people subscribe to may have changed, and new feeds have titles now: share it. // What people subscribe to may have changed, and new feeds have titles now: share it.
ctx.directory_wake.notify_one(); ctx.directory_wake.notify_one();
Ok(()) Ok(())
@@ -1628,9 +1644,14 @@ async fn scan_one(
feed_cfg: &config::Feed, feed_cfg: &config::Feed,
state: &db::HttpState, state: &db::HttpState,
force: bool, force: bool,
// The body the scan already fetched for it, if it did (#104), and where it moved, if it did. // The body the scan already fetched for it, if it did (#104), and where it moved, if it did;
prefetched: Option<Result<(feed::Fetched, Option<String>)>>, // with the site's icon when a forced scan looked it up meanwhile, "" for none (#228).
prefetched: Option<(Result<(feed::Fetched, Option<String>)>, Option<String>)>,
) -> Result<Outcome> { ) -> Result<Outcome> {
let (prefetched, icon) = match prefetched {
Some((got, icon)) => (Some(got), icon),
None => (None, None),
};
// The queue follows the feed's settings each time it is due, changed or not, so files a // The queue follows the feed's settings each time it is due, changed or not, so files a
// limit or auto-download no longer reaches stop counting as waiting (#113). // limit or auto-download no longer reaches stop counting as waiting (#113).
let policy = policy_for(ctx, id, feed_cfg).await?; let policy = policy_for(ctx, id, feed_cfg).await?;
@@ -1732,13 +1753,16 @@ async fn scan_one(
&& stored.image.as_deref() != Some(art.as_str()) && stored.image.as_deref() != Some(art.as_str())
&& !feed::is_image(&ctx.client, art).await && !feed::is_image(&ctx.client, art).await
{ {
tracing::info!(feed = id, art = %art, "the feed's artwork is not an image; trying its site's icon"); tracing::info!(feed = id, art = %feed::redact(art), "the feed's artwork is not an image; trying its site's icon");
parsed.image = None; parsed.image = None;
} }
if parsed.image.is_none() { if parsed.image.is_none() {
parsed.image = Some(match (&stored.image, &parsed.site) { parsed.image = Some(match (&stored.image, &parsed.site) {
(Some(known), _) if !force => known.clone(), (Some(known), _) if !force => known.clone(),
(_, Some(site)) => feed::site_icon(&ctx.client, site).await.unwrap_or_default(), (_, Some(site)) => match &icon {
Some(found) => found.clone(),
None => feed::site_icon(&ctx.client, site).await.unwrap_or_default(),
},
(_, None) => String::new(), (_, None) => String::new(),
}); });
} }
@@ -2297,7 +2321,7 @@ async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
} }
} }
// Terminal, so a UI waiting on this request stops here. // Terminal, so a UI waiting on this request stops here.
ctx.out.emit(Event::ScanDone { feeds: 1 }); ctx.out.emit(Event::ScanDone { feeds: 1, only: None, forced: false });
Ok(()) Ok(())
} }
@@ -2659,6 +2683,58 @@ mod tests {
assert!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().is_none()); assert!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().is_none());
} }
#[tokio::test]
async fn two_people_play_one_file_each_in_their_own_version_and_place() {
let ctx = test_ctx(config::Config::default()).await;
let db = &ctx.db;
for sql in [
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
] {
db.sql(sql).await.unwrap();
}
db.store_plans(7, r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
"500": {"cuts": [[60000,100000]], "pieces": []}}"#).await.unwrap();
// One file on disk, three listeners at once: DeadAir at the usual pause, at a longer
// one, and the original.
let (usual, long, plain) = (
db.create_user("usual", None, false).await.unwrap(),
db.create_user("long", None, false).await.unwrap(),
db.create_user("plain", None, false).await.unwrap(),
);
for u in [usual, long, plain] {
db.subscribe(u, "f").await.unwrap();
}
for u in [usual, long] {
db.set_deadair(u, "f", Some(true)).await.unwrap();
}
db.set_deadair_pause(long, "f", 500).await.unwrap();
// Each is handed their own playlist, as /media/7/deadair.m3u8 does.
let playing = deadair::Playing::default();
for u in [usual, long] {
let plan = deadair::plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(plan));
}
assert!(deadair::plan_for(&ctx, plain, "f", 7).await.unwrap().is_none());
// All three save 3:20 of what they hear, as the position route does: each is read in
// that person's own clock and kept as a place in the original, theirs alone.
let mut kept = vec![];
for u in [usual, long, plain] {
let (secs, len) = deadair::on_save(&ctx, &playing, u, "f", "g", 200, None).await.unwrap();
db.set_position(u, "f", "g", secs, len).await.unwrap();
kept.push(secs);
}
assert_eq!(kept, [320, 240, 200], "two minutes cut before 3:20, forty seconds, none");
// Each comes back to 3:20 of their own version, whatever the others saved.
let order = db::order_sql("published", "desc");
for (u, len) in [(usual, 480), (long, 560), (plain, 600)] {
let mut rows = db.entries_in(u, Some("f"), db::Filter::All, None, 0, 10, &order).await.unwrap();
deadair::for_reader(&ctx, u, &mut rows).await.unwrap();
assert_eq!((rows[0].position, rows[0].duration), (200, Some(len)), "user {u}");
}
}
#[tokio::test] #[tokio::test]
async fn two_changes_to_the_catalogue_at_once_both_keep() { async fn two_changes_to_the_catalogue_at_once_both_keep() {
let ctx = Arc::new(test_ctx(config::Config::default()).await); let ctx = Arc::new(test_ctx(config::Config::default()).await);
@@ -2758,6 +2834,18 @@ mod tests {
assert_eq!(text().await, ["As it should read."]); assert_eq!(text().await, ["As it should read."]);
} }
#[tokio::test]
async fn a_forced_scan_takes_the_icon_looked_up_ahead() {
let ctx = Arc::new(test_ctx(config::Config::default()).await);
// No artwork, and a site nothing answers at: only the icon found ahead can be stored (#228).
let body = b"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><link>http://127.0.0.1:9/</link><item><title>One</title><guid>g1</guid></item></channel></rss>".to_vec();
let got = feed::Fetched::Body { bytes: body, etag: None, last_modified: None };
let icon = "https://example.com/favicon.ico".to_string();
let cfg = config::Feed { url: "http://127.0.0.1:9/feed".into(), ..feed() };
scan_one(&ctx, "iconic", &cfg, &db::HttpState::default(), true, Some((Ok((got, None)), Some(icon.clone())))).await.unwrap();
assert_eq!(ctx.db.feed_summary("iconic").await.unwrap().image, Some(icon));
}
#[tokio::test] #[tokio::test]
async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() { async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() {
let base = empty_or_feed_server().await; let base = empty_or_feed_server().await;

View File

@@ -1907,7 +1907,7 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
Err(e) => { Err(e) => {
let why = format!("{e:#}"); let why = format!("{e:#}");
// The access line says only that it failed, not which image or why (#169). // The access line says only that it failed, not which image or why (#169).
tracing::warn!(url = %q.u, error = %why, "could not fetch artwork"); tracing::warn!(url = %crate::feed::redact(&q.u), error = %crate::feed::redact_text(&why), "could not fetch artwork");
crate::art::fail(&q.u, &why); crate::art::fail(&q.u, &why);
(StatusCode::BAD_GATEWAY, why).into_response() (StatusCode::BAD_GATEWAY, why).into_response()
} }

View File

@@ -51,8 +51,12 @@ pid = 0
def panel(kind, title, w, h, x, targets, **extra): def panel(kind, title, w, h, x, targets, **extra):
global pid global pid
pid += 1 pid += 1
sources = {t.get("datasource", LOKI)["uid"] for t in targets}
# A panel with queries on more than one data source must be Mixed, or Grafana runs them all on
# the panel's and Downloads, Loki's and Prometheus's, failed (#226).
ds = {"type": "datasource", "uid": "-- Mixed --"} if len(sources) > 1 else targets[0].get("datasource", LOKI)
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h}, p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
"datasource": targets[0].get("datasource", LOKI), "targets": targets} "datasource": ds, "targets": targets}
p.update(extra) p.update(extra)
panels.append(p) panels.append(p)
return p return p

View File

@@ -1,6 +1,6 @@
{ {
"name": "ipx-web", "name": "ipx-web",
"version": "1.0.1", "version": "1.0.2",
"private": true, "private": true,
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.", "description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
"scripts": { "scripts": {

132
tests/page-unit.js Normal file
View File

@@ -0,0 +1,132 @@
// Unit tests for the page's own functions (#234): the script as shipped, loaded into the stub DOM
// page-smoke.js uses, and its helpers called directly. The browser suite tests what people do;
// this tests the small pieces it is made of, in a second, with nothing running.
//
// node --test tests/page-unit.js
//
// The files in web/src are one script, not modules. Its top-level `function`s are properties of
// the context; its top-level `const`s are not, but a script run in the same context sees them, as
// a second <script> on a page would, so `run` evaluates an expression there.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeContext, vm } = require('./dom-stub.js');
const { buildPage } = require('../web/build.mjs');
const { html, js } = buildPage('index.html');
const { ctx } = makeContext(html, js);
vm.createContext(ctx);
vm.runInContext(js, ctx, { filename: 'index.html<script>', timeout: 5000 });
const run = expr => vm.runInContext(expr, ctx);
// Time as the page sees it, for the helpers that count from now.
const NOW = 1_800_000_000;
run(`Date.now = () => ${NOW * 1000}`);
test('esc makes text safe to put in markup and attributes', () => {
assert.equal(run(`esc('<a href="x">Tom & Jerry\\'s</a>')`), '&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#39;s&lt;/a&gt;');
assert.equal(run('esc(null)'), '');
assert.equal(run('esc(undefined)'), '');
});
test('clock reads seconds as a player does', () => {
assert.equal(run('clock(0)'), '0:00');
assert.equal(run('clock(65)'), '1:05');
assert.equal(run('clock(3661)'), '1:01:01');
assert.equal(run('clock(59.9)'), '0:59');
assert.equal(run('clock(-5)'), '0:00');
assert.equal(run('clock(undefined)'), '0:00');
});
test('mb sizes a file in the unit that reads best', () => {
assert.equal(run('mb(0)'), '');
// An article's small image is not "0 KB" or "0 MB".
assert.equal(run('mb(100)'), '1 KB');
assert.equal(run('mb(2048)'), '2 KB');
assert.equal(run('mb(5 * 1048576)'), '5 MB');
assert.equal(run('mb(1.5 * 1073741824)'), '1.5 GB');
});
test('ago says how long since, and never for nothing', () => {
assert.equal(run('ago(0)'), 'never');
assert.equal(run(`ago(${NOW - 10})`), '1m ago');
assert.equal(run(`ago(${NOW - 45 * 60})`), '45m ago');
assert.equal(run(`ago(${NOW - 5 * 3600})`), '5h ago');
assert.equal(run(`ago(${NOW - 3 * 86400})`), '3d ago');
// Past a month, a date: whatever the locale, it names the year.
assert.match(run(`ago(${NOW - 90 * 86400})`), /2026/);
});
test('due says how long until a feed is checked', () => {
assert.equal(run(`due(${NOW - 1})`), 'due now');
assert.equal(run(`due(${NOW + 10})`), 'in 1m');
assert.equal(run(`due(${NOW + 90 * 60})`), 'in 2h');
assert.equal(run(`due(${NOW + 3 * 86400})`), 'in 3d');
});
test('DeadAir savings read as time', () => {
assert.equal(run('spanText(40)'), '40 s');
assert.equal(run('spanText(12 * 60 + 5)'), '12 min');
assert.equal(run('spanText(3 * 3600 + 12 * 60)'), '3 h 12 min');
assert.equal(run('shorter(45)'), '45 s shorter');
assert.equal(run('shorter(150)'), '3 min shorter');
});
test('initials and plural', () => {
assert.equal(run(`initials('The Verge')`), 'TV');
assert.equal(run(`initials('accidental tech podcast')`), 'AT');
assert.equal(run(`initials('')`), '?');
assert.equal(run(`initials('!!!')`), '?');
assert.equal(run(`plural(1, 'feed')`), '1 feed');
assert.equal(run(`plural(0, 'feed')`), '0 feeds');
assert.equal(run(`plural(2, 'feed')`), '2 feeds');
});
test("kindOf names a file by its type, or else its address's extension", () => {
const kind = enc => run(`kindOf(${JSON.stringify(enc)})`);
assert.equal(kind({ mime: 'image/png' }), 'image');
assert.equal(kind({ mime: 'Video/MP4' }), 'video');
assert.equal(kind({ mime: 'audio/mpeg' }), 'audio');
assert.equal(kind({ mime: 'application/pdf' }), 'pdf');
assert.equal(kind({ mime: 'application/x-bittorrent' }), 'torrent');
assert.equal(kind({ url: 'https://x/ep.MP3?token=1' }), 'mp3');
assert.equal(kind({ url: 'https://x/download' }), 'file');
assert.equal(kind({ url: 'https://x/a.toolongext' }), 'file');
assert.equal(kind({}), 'file');
});
test('an item without a title is named by its file', () => {
const name = e => run(`entryName(${JSON.stringify(e)})`);
assert.deepEqual({ ...name({ title: 'Episode 1' }) }, { text: 'Episode 1', derived: false });
assert.deepEqual({ ...name({ title: ' ', enclosures: [{ url: 'https://x/My%20Show%20101.mp3?x=1' }] }) },
{ text: 'My Show 101', derived: true });
// A name that does not decode is shown as it is, not dropped.
assert.deepEqual({ ...name({ enclosures: [{ url: 'https://x/100%.mp3' }] }) }, { text: '100%', derived: true });
});
test('the directory sorts by name as people read, and by subscribers', () => {
const sorted = (list, by) => run(`${JSON.stringify(list)}.sort(${by}).map(p => p.title)`);
assert.deepEqual([...sorted([{ title: 'ep 10' }, { title: 'Ep 2' }, { title: 'ep 1' }], 'byName')], ['ep 1', 'Ep 2', 'ep 10']);
assert.deepEqual([...sorted([{ title: 'B', subscribers: 1 }, { title: 'C', subscribers: 5 }, { title: 'A', subscribers: 1 }], 'bySubs')],
['C', 'A', 'B']);
});
test('feeds with something unread go first, in the order they came', () => {
const ids = run(`[{id:'a',unread:0},{id:'b',unread:3},{id:'c',unread:0},{id:'d',unread:1}].sort(unreadFirst).map(f => f.id)`);
assert.deepEqual([...ids], ['b', 'd', 'a', 'c']);
});
test('words for filters split on commas, blanks dropped', () => {
assert.deepEqual([...run(`splitWords(' one, two words ,, three ')`)], ['one', 'two words', 'three']);
assert.deepEqual([...run(`splitWords('')`)], []);
});
test("a file plays from DeadAir's playlist when it has one", () => {
assert.equal(run(`mediaSrc({ id: 7, deadair: null })`), '/media/7');
assert.equal(run(`mediaSrc({ id: 7 })`), '/media/7');
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
});
test('a Patreon feed is told by its address', () => {
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
assert.equal(run('isPatreon(null)'), false);
});

View File

@@ -1697,15 +1697,30 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => { test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' }); const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await page.evaluate(() => setScanning('test-show', true)); // A check of a few milliseconds leaves the count where it is, or every count blinked as a scan
// went by; the spinner comes once the check has taken a moment (#232).
const atOnce = await page.evaluate(() => {
setScanning('test-show', true);
return document.querySelector('#feedlist .feed[data-id="test-show"]').classList.contains('scanning');
});
expect(atOnce, 'no spinner the moment a check starts').toBe(false);
await expect(row).toHaveClass(/\bscanning\b/); await expect(row).toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden(); await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
await page.evaluate(() => setScanning('test-show', false)); await page.evaluate(() => setScanning('test-show', false));
await expect(row).not.toHaveClass(/\bscanning\b/); await expect(row).not.toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge')).toBeVisible(); await expect(row.locator('.badge')).toBeVisible();
// The button turns from the press to the end of the scan it asked for (#233): a scheduled
// scan's end, or the end of a scan of one other feed, leaves it turning.
const spins = () => document.body.classList.contains('scan-any');
expect(await page.evaluate(spins => { spinStart(null); spinEnd({ only: null, forced: false });
const a = document.body.classList.contains('scan-any'); spinEnd({ only: 'test-show', forced: true });
return a && document.body.classList.contains('scan-any'); })).toBe(true);
await page.evaluate(() => spinEnd({ only: null, forced: true }));
expect(await page.evaluate(spins)).toBe(false);
// Checking every feed says so on the rows, not in a toast (issue #37). // Checking every feed says so on the rows, not in a toast (issue #37).
await page.locator('#scanAll').click(); expect(await page.evaluate(() => { scanAll(); return document.body.classList.contains('scan-any'); }),
await page.waitForTimeout(1500); 'turning the moment it is pressed').toBe(true);
await expect(page.locator('body')).not.toHaveClass(/\bscan-any\b/, { timeout: 15_000 });
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0); await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
}); });
@@ -1766,20 +1781,28 @@ test('Share hands the feed, the item and the file to the share sheet', async ({
]); ]);
}); });
test('a refresh button turns while what it checks is being checked', async ({ page }) => { test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('.feed', { hasText: 'Test Show' }).click();
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName); const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
const btn = '.fhead [data-a=scan] .i'; const btn = '.fhead [data-a=scan] .i';
await expect(page.locator(btn)).toBeVisible(); await expect(page.locator(btn)).toBeVisible();
expect(await spin(btn)).toBe('none'); expect(await spin(btn)).toBe('none');
await page.evaluate(() => setScanning('test-show', true)); // Pressed: this feed's button turns, the toolbar's does not (#78, #233).
await page.evaluate(() => spinStart('test-show'));
expect(await spin(btn)).toBe('ipxspin'); expect(await spin(btn)).toBe('ipxspin');
expect(await spin('#scanAll .i')).toBe('ipxspin'); expect(await spin('#scanAll .i')).toBe('none');
// Another feed being checked turns the toolbar's, not this page's. // Feeds being checked one by one no longer start or stop it (#231).
await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); }); await page.evaluate(() => { setScanning('test-show', true); setScanning('test-show', false); });
expect(await spin(btn)).toBe('ipxspin');
// Another feed's scan ending, or a scheduled one's, leaves it turning; its own stops it.
await page.evaluate(() => { spinEnd({ only: 'multi-show', forced: true }); spinEnd({ only: null, forced: false }); });
expect(await spin(btn)).toBe('ipxspin');
await page.evaluate(() => spinEnd({ only: 'test-show', forced: true }));
expect(await spin(btn)).toBe('none'); expect(await spin(btn)).toBe('none');
// The toolbar's turns for a scan of every feed, until that scan ends.
await page.evaluate(() => spinStart(null));
expect(await spin('#scanAll .i')).toBe('ipxspin'); expect(await spin('#scanAll .i')).toBe('ipxspin');
await page.evaluate(() => setScanning('multi-show', false)); await page.evaluate(() => spinEnd({ only: null, forced: true }));
expect(await spin('#scanAll .i')).toBe('none'); expect(await spin('#scanAll .i')).toBe('none');
}); });
@@ -1913,6 +1936,36 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y
await page.locator('#ssave').click(); await page.locator('#ssave').click();
await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual); await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
// Someone else on the same show at the usual pause, at the same address at the same time: one
// file on disk, a playlist each, and neither kept by a cache to be handed to the other.
{
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
try { execFileSync('./target/debug/ipx', ['user', 'add', 'quinn'], { input: 'quinnpassword', env }); }
catch (e) { if (!String(e.stderr || e.stdout).includes('already exists')) throw e; }
const ctx = await page.context().browser().newContext();
const quinn = await ctx.newPage();
await quinn.goto('/login');
await quinn.locator('#name').fill('quinn');
await quinn.locator('#pw').fill('quinnpassword');
await quinn.locator('button[type=submit]').click();
await expect(quinn.locator('#feedlist')).toBeVisible();
await quinn.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } });
await quinn.request.patch(`/api/feeds/${encodeURIComponent(id)}`, { data: { deadair: true } });
const enc = (await entry()).enclosures[0].id;
const fetchList = async p => { const r = await p.request.get(`/media/${enc}/deadair.m3u8`);
return { status: r.status(), cache: r.headers()['cache-control'], body: await r.text() }; };
const [mine, theirs] = await Promise.all([fetchList(page), fetchList(quinn)]);
const length = l => l.body.match(/#EXTINF:([\d.]+)/g).reduce((s, x) => s + Number(x.slice(8)), 0);
expect([mine.status, theirs.status]).toEqual([200, 200]);
expect([mine.cache, theirs.cache]).toEqual(['no-store', 'no-store']);
expect(mine.body).not.toBe(theirs.body);
expect(length(theirs), 'the usual pause cuts more than the longer one').toBeLessThan(length(mine));
await quinn.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
await ctx.close();
}
// Saved without its dead air too (#186): smaller than the original, named to say so. // Saved without its dead air too (#186): smaller than the original, named to say so.
const saved = await page.evaluate(async id => { const saved = await page.evaluate(async id => {
const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]); const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]);

View File

@@ -5,7 +5,9 @@
"target": "es2022", "target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"], "lib": ["es2022", "dom", "dom.iterable"],
"noEmit": true, "noEmit": true,
"strict": false, // Strict throughout (#235, #236). $ assumes the element is there: the page check proves the
// ones wired at load exist, and a lookup that may find nothing says so with ?.
"strict": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["web/src/*.ts"] "include": ["web/src/*.ts"]

View File

@@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.0.2] - 2026-10-15
### Fixed
- The refresh buttons turn from the moment they are pressed until the scan they asked for has finished, smoothly, instead of jerking back upright between one feed and the next (#231, #233).
- A feed's unread count stays put while a scan checks it quickly; the spinner takes its place only when a check takes a moment (#232).
### Security
- The ipx-web image no longer carries curl, which Caddy's base image shipped and nothing used: 8.22.0 there is affected by CVE-2026-92392 (#237).
## [1.0.1] - 2026-10-13 ## [1.0.1] - 2026-10-13
### Fixed ### Fixed
@@ -38,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212). - On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...main [unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.2...main
[1.0.2]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...web-v1.0.2
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1 [1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0 [1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0

View File

@@ -56,17 +56,17 @@ async function drawServer(){
<div class="field"><label>Download folder</label> <div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div> <span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`; <div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
$('#gsave').onclick = async () => { $<HTMLButtonElement>('#gsave').onclick = async () => {
try{ try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({ await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`, schedule: `every ${Math.max(1, Number($<HTMLInputElement>('#gnum').value) || 1)}${$<HTMLSelectElement>('#gunit').value}`,
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0), max_new_per_check: Math.max(0, Number($<HTMLInputElement>('#gmax').value) || 0),
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean), media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
max_total_gb: Number($('#gquota').value) || 0, max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
max_age_days: Number($('#gage').value) || 0, max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})}); art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
toast('Settings saved'); toast('Settings saved');
}catch(e){ toast(e.message, true); } }catch(e){ toast(why(e), true); }
}; };
} }
@@ -99,15 +99,15 @@ async function drawDirectorySettings(){
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)}); await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
toast(said); toast(said);
drawDirectorySettings(); drawDirectorySettings();
}catch(e){ toast(e.message, true); } }catch(e){ toast(why(e), true); }
}; };
$('#dsave').onclick = () => { $<HTMLButtonElement>('#dsave').onclick = () => {
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked}; const patch: Record<string, unknown> = {directory_url: $<HTMLInputElement>('#durl').value.trim(), directory_share: $<HTMLInputElement>('#dshare').checked};
// Blank leaves the token as it is; Clear is the way to remove it. // Blank leaves the token as it is; Clear is the way to remove it.
if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim(); if($<HTMLInputElement>('#dtoken').value.trim()) patch.directory_token = $<HTMLInputElement>('#dtoken').value.trim();
save(patch, 'Directory settings saved'); save(patch, 'Directory settings saved');
}; };
const clear = $('#dclear'); const clear = $<HTMLButtonElement>('#dclear');
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared'); if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
drawDirectoryStatus(); drawDirectoryStatus();
} }
@@ -115,7 +115,7 @@ async function drawDirectorySettings(){
async function drawDirectoryStatus(){ async function drawDirectoryStatus(){
const el = $('#dstatus'); const el = $('#dstatus');
let s; let s;
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = e.message; return; } try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = why(e); return; }
if(!el.isConnected) return; if(!el.isConnected) return;
const lines: string[] = []; const lines: string[] = [];
if(!s.configured) lines.push('Not set up: it needs an address and a token.'); if(!s.configured) lines.push('Not set up: it needs an address and a token.');
@@ -137,8 +137,8 @@ async function drawDirectoryStatus(){
/// sharing with it, and the feeds removed from it. /// sharing with it, and the feeds removed from it.
async function drawDirectoryAdmin(){ async function drawDirectoryAdmin(){
const box = $('#dadmin'); const box = $('#dadmin');
let a; let a: DirAdmin;
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(e.message)}</p>`; return; } try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; }
if(!box.isConnected) return; if(!box.isConnected) return;
box.innerHTML = `<h2>Servers</h2> box.innerHTML = `<h2>Servers</h2>
<p class="hint">Each server sharing with the directory, by its token's name. Dropping one removes its <p class="hint">Each server sharing with the directory, by its token's name. Dropping one removes its
@@ -155,29 +155,29 @@ async function drawDirectoryAdmin(){
<small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div> <small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div>
<button class="btn" data-a="unblock">Unblock</button></div>`).join('') <button class="btn" data-a="unblock">Unblock</button></div>`).join('')
: '<p class="hint">Nothing is blocked.</p>'}`; : '<p class="hint">Nothing is blocked.</p>'}`;
box.onclick = async ev => { box.onclick = async (ev: MouseEvent) => {
const b = (ev.target as Element).closest<HTMLElement>('[data-a]'); const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
if(!b) return; if(!b) return;
const row = b.closest<HTMLElement>('.urow'); const row = b.closest<HTMLElement>('.urow');
try{ try{
if(b.dataset.a === 'drop'){ if(b.dataset.a === 'drop'){
const name = row.dataset.name; const name = row!.dataset.name!;
if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return; if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return;
await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'}); await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'});
toast(`Dropped ${name}'s snapshot`); toast(`Dropped ${name}'s snapshot`);
}else if(b.dataset.a === 'unblock'){ }else if(b.dataset.a === 'unblock'){
await api(`/api/directory-blocked/${encodeURIComponent(row.dataset.id)}`, {method: 'DELETE'}); await api(`/api/directory-blocked/${encodeURIComponent(row!.dataset.id!)}`, {method: 'DELETE'});
toast('Unblocked. It comes back with the next snapshot that has it'); toast('Unblocked. It comes back with the next snapshot that has it');
} }
drawDirectoryAdmin(); drawDirectoryAdmin();
}catch(e){ toast(e.message, true); } }catch(e){ toast(why(e), true); }
}; };
} }
/* ---------------- accounts ---------------- */ /* ---------------- accounts ---------------- */
async function drawAccounts(){ async function drawAccounts(){
const box = $('#accounts'); const box = $('#accounts');
const users = await api('/api/users') || []; const users: Account[] = await api('/api/users') || [];
box.innerHTML = `<h2>Accounts</h2> box.innerHTML = `<h2>Accounts</h2>
${users.map(u => `<div class="inline urow" data-id="${u.id}"> ${users.map(u => `<div class="inline urow" data-id="${u.id}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b> <div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
@@ -195,44 +195,44 @@ async function drawAccounts(){
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in <span class="hint">At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.</span></div> through the proxy. New people start with no feeds.</span></div>
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`; <div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
const change = async (u, opts) => { const change = async (u: Account, opts: RequestInit) => {
try{ try{
await api(`/api/users/${u.id}`, opts); await api(`/api/users/${u.id}`, opts);
// Demoting yourself takes this page away; go back to the app rather than stay on a page // Demoting yourself takes this page away; go back to the app rather than stay on a page
// the server no longer answers. Only on success: a refusal's toast has to stay readable. // the server no longer answers. Only on success: a refusal's toast has to stay readable.
if(u.name === me?.name){ location.href = '/'; return; } if(u.name === me?.name){ location.href = '/'; return; }
}catch(e){ toast(e.message, true); } }catch(e){ toast(why(e), true); }
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
}; };
for(const row of $$('#accounts [data-id]')){ for(const row of $$('#accounts [data-id]')){
const u = users.find(x => String(x.id) === row.dataset.id); const u = users.find(x => String(x.id) === row.dataset.id)!;
$('[data-a="admin"]', row).onchange = e => $('[data-a="admin"]', row).onchange = (e: Event) =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})}); change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).checked})});
$('[data-a="rm"]', row).onclick = () => { $('[data-a="rm"]', row).onclick = () => {
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
change(u, {method: 'DELETE'}); change(u, {method: 'DELETE'});
}; };
} }
$('#uadd').onclick = async () => { $<HTMLButtonElement>('#uadd').onclick = async () => {
try{ try{
await api('/api/users', {method: 'POST', body: JSON.stringify({ await api('/api/users', {method: 'POST', body: JSON.stringify({
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})}); name: $<HTMLInputElement>('#uname').value, password: $<HTMLInputElement>('#upass').value, admin: $<HTMLInputElement>('#uadmin').checked})});
toast('Added'); drawAccounts(); toast('Added'); drawAccounts();
}catch(e){ toast(e.message, true); } // keep what was typed }catch(e){ toast(why(e), true); } // keep what was typed
}; };
} }
/* ---------------- log ---------------- */ /* ---------------- log ---------------- */
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all'; let logTimer: number|null = null, logSeq = 0, logLines: LogLine[] = [], logFilter = '', logLevel = '', logTab = 'all';
// Which sources belong to each tab. "daemon" is the control protocol itself: every // Which sources belong to each tab. "daemon" is the control protocol itself: every
// command in and every event out, whatever sent it. // command in and every event out, whatever sent it.
const LOG_TABS = { const LOG_TABS: Record<string, ((t: string) => boolean)|null> = {
all: null, all: null,
daemon: t => t === 'ipx::io', daemon: t => t === 'ipx::io',
scan: t => t === 'ipx::scan', scan: t => t === 'ipx::scan',
web: t => t === 'ipx::http', web: t => t === 'ipx::http',
}; };
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0}; const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
function drawLogView(){ function drawLogView(){
logSeq = 0; logLines = []; logSeq = 0; logLines = [];
@@ -259,14 +259,14 @@ function drawLogView(){
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
what it captures.</span>`; what it captures.</span>`;
for(const b of $$('#logtabs button')) b.onclick = () => { for(const b of $$('#logtabs button')) b.onclick = () => {
logTab = b.dataset.t; logTab = b.dataset.t!;
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab); for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
drawLog(); drawLog();
}; };
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); }; $<HTMLSelectElement>('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); };
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); }; $<HTMLInputElement>('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); };
$('#logcopy').onclick = () => copyText(visibleLog().map(l => $<HTMLButtonElement>('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); `${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $<HTMLButtonElement>('#logcopy'));
pollLog(); pollLog();
if(!logTimer) logTimer = setInterval(pollLog, 2000); if(!logTimer) logTimer = setInterval(pollLog, 2000);
} }
@@ -281,7 +281,7 @@ async function pollLog(){
}else if(!logLines.length){ drawLog(); } }else if(!logLines.length){ drawLog(); }
}catch(e){ }catch(e){
const box = $('#logbox'); const box = $('#logbox');
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`; if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(why(e))}</p>`;
} }
} }
function visibleLog(){ function visibleLog(){
@@ -294,7 +294,7 @@ function visibleLog(){
} }
function drawLog(){ function drawLog(){
const box = $('#logbox'); if(!box) return; const box = $('#logbox'); if(!box) return;
const follow = $('#logfollow')?.checked; const follow = $<HTMLInputElement>('#logfollow')?.checked;
const rows = visibleLog(); const rows = visibleLog();
box.innerHTML = rows.length ? rows.map(l => { box.innerHTML = rows.length ? rows.map(l => {
const t = new Date(l.ts * 1000).toLocaleTimeString(); const t = new Date(l.ts * 1000).toLocaleTimeString();

View File

@@ -9,9 +9,9 @@ function closeModal(){
$('#modal').classList.remove('on'); $('#modal').classList.remove('on');
} }
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); }; $('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
$('#addFeed').onclick=()=>{ $<HTMLButtonElement>('#addFeed').onclick=()=>{
openModal(`<h3>Add a feed</h3> openModal(`<h3>Add a feed</h3>
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss"> <div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div> <span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
@@ -20,17 +20,17 @@ $('#addFeed').onclick=()=>{
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label> <label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`); <button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
$('#nurl').focus(); $<HTMLInputElement>('#nurl').focus();
$('#nsave').onclick=async()=>{ $<HTMLButtonElement>('#nsave').onclick=async()=>{
const url=$('#nurl').value.trim(); if(!url) return; const url=$<HTMLInputElement>('#nurl').value.trim(); if(!url) return;
$('#nsave').disabled=true; $('#nsave').title='Adding…'; $<HTMLButtonElement>('#nsave').disabled=true; $<HTMLButtonElement>('#nsave').title='Adding…';
try{ try{
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({ const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
url, allow_explicit:$('#nexp').checked, url, allow_explicit:$<HTMLInputElement>('#nexp').checked,
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})}); keywords:$<HTMLInputElement>('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})});
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`); closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
await loadFeeds(true); selectFeed(r.id); await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; } }catch(e){ toast(why(e),true); $<HTMLButtonElement>('#nsave').title='Add feed'; $<HTMLButtonElement>('#nsave').disabled=false; }
}; };
}; };
@@ -43,7 +43,7 @@ let dirError='';
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The /// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its /// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
/// page in the Directory, to see what it is before you take it (#128). /// page in the Directory, to see what it is before you take it (#128).
function listedFeed(p,cls,rank?: number){ function listedFeed(p: DirFeed,cls: string,rank?: number){
const el=document.createElement('div'); const el=document.createElement('div');
el.className=cls+(p.subscribed?' mine':''); el.className=cls+(p.subscribed?' mine':'');
el.tabIndex=0; el.dataset.id=p.id; el.tabIndex=0; el.dataset.id=p.id;
@@ -64,47 +64,47 @@ function listedFeed(p,cls,rank?: number){
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; } if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); }; el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
// Its own click, not the tile's as well. // Its own click, not the tile's as well.
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); }; $('[data-a="sub"]',el).onclick=(ev: Event)=>{ ev.stopPropagation(); subscribeListed(p); };
return el; return el;
} }
async function subscribeListed(p){ async function subscribeListed(p: DirFeed){
try{ try{
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'}); const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
toast(`Subscribed to ${NAME(p)}`); toast(`Subscribed to ${NAME(p)}`);
await loadFeeds(true); selectFeed(r.id); await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); } }catch(e){ toast(why(e),true); }
} }
// Where you are in the Directory: what kind of feed, which category and subcategory, in what // Where you are in the Directory: what kind of feed, which category and subcategory, in what
// order. Out here because a search redraws the page, and the kind and order hold across visits. // order. Out here because a search redraws the page, and the kind and order hold across visits.
let dirKind='All', dirCat=null, dirSub=null, dirSort='az', dirRows=[]; let dirKind='All', dirCat: string|null=null, dirSub: string|null=null, dirSort='az', dirRows: DirFeed[]=[];
// The feed whose page is open over all that, by id; going back leaves the rest as it was. // The feed whose page is open over all that, by id; going back leaves the rest as it was.
let dirShow=null; let dirShow: string|null=null;
// The two pages that are not a category: every feed, and the feeds that name none. A category is // The two pages that are not a category: every feed, and the feeds that name none. A category is
// Apple's or one an admin typed, neither of which starts with a colon. // Apple's or one an admin typed, neither of which starts with a colon.
const EVERY=':every', OTHER=':other'; const EVERY=':every', OTHER=':other';
const NAME=p=>(p.title||p.id); const NAME=(p: {title: string|null, id: string})=>(p.title||p.id);
const byName=(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true}); const byName=(a: DirFeed,b: DirFeed)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
const bySubs=(a,b)=>b.subscribers-a.subscribers||byName(a,b); const bySubs=(a: DirFeed,b: DirFeed)=>b.subscribers-a.subscribers||byName(a,b);
/// The grid's orders. It opens A to Z, as the server sends it. /// The grid's orders. It opens A to Z, as the server sends it.
const DIR_SORTS={ const DIR_SORTS: Record<string,[string,(a: DirFeed,b: DirFeed)=>number]>={
subscribers:['Most subscribers',bySubs], subscribers:['Most subscribers',bySubs],
az:['Name, A to Z',byName], az:['Name, A to Z',byName],
za:['Name, Z to A',(a,b)=>byName(b,a)], za:['Name, Z to A',(a: DirFeed,b: DirFeed)=>byName(b,a)],
}; };
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast}; const KINDS: Record<string,(p: DirFeed)=>boolean>={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'}; const KIND_WORD: Record<string,string>={All:'feed',Podcasts:'podcast',Blogs:'blog'};
/// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the /// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the
/// categories, and a category has a page of its own, its subcategories along the top of its /// categories, and a category has a page of its own, its subcategories along the top of its
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across /// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
/// every page, and the search box looks through the lot. One fetch draws all of it. /// every page, and the search box looks through the lot. One fetch draws all of it.
async function renderDirectory(url,box){ async function renderDirectory(url: string,box: HTMLElement){
// Each visit starts at the front, as going back to a tab does. // Each visit starts at the front, as going back to a tab does.
dirCat=null; dirSub=null; dirShow=null; dirCat=null; dirSub=null; dirShow=null;
dirError=''; dirError='';
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=e.message; } try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=why(e); }
drawDirectory(box); drawDirectory(box);
return dirRows.length; return dirRows.length;
} }
@@ -117,24 +117,24 @@ function drawDirectory(box=$('#directory'), focus?: string){
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs); const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
if(!both) dirKind='All'; if(!both) dirKind='All';
const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind]; const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind];
const count=n=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`; const count=(n: number)=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
const of=c=>p=>c===EVERY||(c===OTHER?!p.category:p.category===c); const of=(c: string)=>(p: DirFeed)=>c===EVERY||(c===OTHER?!p.category:p.category===c);
// A kind can leave a category with nothing in it; that goes back to the front. // A kind can leave a category with nothing in it; that goes back to the front.
if(dirCat&&!rows.some(of(dirCat))) dirCat=null; if(dirCat&&!rows.some(of(dirCat))) dirCat=null;
const catName=c=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c; const catName=(c: string)=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
const top=list=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10); const top=(list: DirFeed[])=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
const btn=(k,v,label,on)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`; const btn=(k: string,v: string,label: string,on: boolean)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`;
// back: where its button goes, '' for the front; none on the front itself. // back: where its button goes, '' for the front; none on the front itself.
const head=(title,sub,back?)=>`<div class="dhead">${back==null?'': const head=(title: string,sub: string,back?: string)=>`<div class="dhead">${back==null?'':
`<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`} `<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`}
<div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${ <div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${
Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div> Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div>
<p class="sub">${esc(sub)}</p></div>`; <p class="sub">${esc(sub)}</p></div>`;
const sec=(title,body,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`; const sec=(title: string,body: string,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`;
const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${ const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${
Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`; Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`;
const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>'; const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>';
let html, chart=[], tiles=null; let html: string, chart: DirFeed[]=[], tiles: DirFeed[]|null=null;
const show=dirShow&&dirRows.find(p=>p.id===dirShow); const show=dirShow&&dirRows.find(p=>p.id===dirShow);
if(show){ if(show){
const n=show.subscribers; const n=show.subscribers;
@@ -154,21 +154,21 @@ function drawDirectory(box=$('#directory'), focus?: string){
html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid; html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid;
}else if(!dirCat){ }else if(!dirCat){
chart=top(rows); chart=top(rows);
const cats=new Map<string,any[]>(); const cats=new Map<string,DirFeed[]>();
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); } for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c)!.push(p); }
// Other, the feeds with no category, goes last. // Other, the feeds with no category, goes last.
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b)); const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`) html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`)
+(chart.length?sec('Most subscribed',board):'') +(chart.length?sec('Most subscribed',board):'')
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}</div>`, +sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c)!.length),cats.get(c)!)).join('')}</div>`,
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`); `<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
}else{ }else{
const inCat=rows.filter(of(dirCat)); const inCat=rows.filter(of(dirCat));
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter(Boolean))].sort(); const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter((s): s is string=>!!s))].sort();
if(!subs.includes(dirSub)) dirSub=null; if(!dirSub||!subs.includes(dirSub)) dirSub=null;
if(dirCat!==EVERY) chart=top(inCat); if(dirCat!==EVERY) chart=top(inCat);
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub); tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
const pill=(v,label,n)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v); const pill=(v: string,label: string,n: number)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v);
html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'') html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'')
+(chart.length?sec('Most subscribed',board):'') +(chart.length?sec('Most subscribed',board):'')
// Its own heading only when the chart is above it, to say where one list ends. // Its own heading only when the chart is above it, to say where one list ends.
@@ -178,25 +178,25 @@ function drawDirectory(box=$('#directory'), focus?: string){
box.innerHTML=html; box.innerHTML=html;
if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); } if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); }
const c=$('#dchart',box); const c=$('#dchart',box);
if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p,i)=>c.appendChild(listedFeed(p,'rank',i+1))); } if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p: DirFeed,i: number)=>c.appendChild(listedFeed(p,'rank',i+1))); }
const t=$('#dtiles',box); const t=$('#dtiles',box);
if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile')); if(t&&tiles) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
box.onclick=ev=>{ box.onclick=(ev: MouseEvent)=>{
const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]'); const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]');
if(!b) return; if(!b) return;
// Back from a feed's page, onto the tile or row it was opened from. // Back from a feed's page, onto the tile or row it was opened from.
if(b.dataset.back!=null){ const id=dirShow; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); } if(b.dataset.back!=null){ const id=dirShow??''; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); }
if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); } if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); }
if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); } if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); }
// Going anywhere ends a search. // Going anywhere ends a search.
if(S.q){ S.q=''; $('#epSearch').value=''; } if(S.q){ S.q=''; $<HTMLInputElement>('#epSearch').value=''; }
const from=dirCat; const from=dirCat;
dirCat=b.dataset.go||null; dirSub=null; dirShow=null; dirCat=b.dataset.go||null; dirSub=null; dirShow=null;
$('#content').scrollTop=0; $('#content').scrollTop=0;
// Into a page, onto its way back; back out, onto the tile you went in by. // Into a page, onto its way back; back out, onto the tile you went in by.
drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined); drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined);
}; };
const s=$('#dirsort',box); const s=$<HTMLSelectElement>('#dirsort',box);
if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); }; if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
if(focus) $(focus,box)?.focus(); if(focus) $(focus,box)?.focus();
} }
@@ -204,11 +204,11 @@ function drawDirectory(box=$('#directory'), focus?: string){
/// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the /// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the
/// rest, and its latest items, each one's title, opening its post on the publisher's site where /// rest, and its latest items, each one's title, opening its post on the publisher's site where
/// it has one, its first lines, and when and how long. /// it has one, its first lines, and when and how long.
async function fillShow(box,p){ async function fillShow(box: HTMLElement,p: DirFeed){
const list=$('#dshow',box), about=$('#dabout',box); const list=$('#dshow',box), about=$('#dabout',box);
let got; let got: {description: string|null, failing: string|null, items: ListedItem[], can_admin: boolean};
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); } try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
catch(e){ list.innerHTML=`<p class="hint">${esc(e.message)}</p>`; return; } catch(e){ list.innerHTML=`<p class="hint">${esc(why(e))}</p>`; return; }
// Gone back, or on to another, while it loaded. // Gone back, or on to another, while it loaded.
if(dirShow!==p.id||!list.isConnected) return; if(dirShow!==p.id||!list.isConnected) return;
// Some feeds give their title again as their description, which says nothing new. // Some feeds give their title again as their description, which says nothing new.
@@ -233,12 +233,12 @@ async function fillShow(box,p){
+`<button class="btn ico danger" data-a="dremove" title="Remove from the directory" aria-label="Remove from the directory">${ICON.trash}</button>`); +`<button class="btn ico danger" data-a="dremove" title="Remove from the directory" aria-label="Remove from the directory">${ICON.trash}</button>`);
$('[data-a="drefresh"]',acts).onclick=async()=>{ $('[data-a="drefresh"]',acts).onclick=async()=>{
try{ await api(`/api/directory/${encodeURIComponent(p.id)}/refresh`,{method:'POST'}); toast('Checked'); fillShow(box,p); } try{ await api(`/api/directory/${encodeURIComponent(p.id)}/refresh`,{method:'POST'}); toast('Checked'); fillShow(box,p); }
catch(e){ toast(e.message,true); } catch(e){ toast(why(e),true); }
}; };
$('[data-a="dremove"]',acts).onclick=async()=>{ $('[data-a="dremove"]',acts).onclick=async()=>{
if(!confirm(`Remove ${NAME(p)} from the directory? It is blocked, so no server's snapshot brings it back. Admin, Directory can unblock it.`)) return; if(!confirm(`Remove ${NAME(p)} from the directory? It is blocked, so no server's snapshot brings it back. Admin, Directory can unblock it.`)) return;
try{ await api(`/api/directory/${encodeURIComponent(p.id)}`,{method:'DELETE'}); } try{ await api(`/api/directory/${encodeURIComponent(p.id)}`,{method:'DELETE'}); }
catch(e){ return toast(e.message,true); } catch(e){ return toast(why(e),true); }
toast(`Removed ${NAME(p)} from the directory`); toast(`Removed ${NAME(p)} from the directory`);
dirRows=dirRows.filter(r=>r.id!==p.id); dirShow=null; dirRows=dirRows.filter(r=>r.id!==p.id); dirShow=null;
drawDirectory(box); drawDirectory(box);
@@ -256,7 +256,7 @@ async function fillShow(box,p){
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed /// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
/// fanned in the corner, so each one is coloured by what is in it, as Apple's are. /// fanned in the corner, so each one is coloured by what is in it, as Apple's are.
function catTile(c,name,count,rows){ function catTile(c: string,name: string,count: string,rows: DirFeed[]){
// Covers before initials, which are all Other had; the likeliest first, and first is in front: // Covers before initials, which are all Other had; the likeliest first, and first is in front:
// the fan is drawn back to front. // the fan is drawn back to front.
const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse(); const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse();
@@ -265,20 +265,20 @@ function catTile(c,name,count,rows){
} }
/// The Directory and Currently Listening open in the main pane, as the original's Directory did. /// The Directory and Currently Listening open in the main pane, as the original's Directory did.
async function renderListed(v){ async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){
const box=$('#content'); const box=$('#content');
box.classList.add('plain'); box.classList.add('plain');
$('#tbRemove').disabled=true; $<HTMLButtonElement>('#tbRemove').disabled=true;
syncTools(null); syncTools(null);
if(v===VIEWS[':directory']){ if(v===VIEWS[':directory']){
$('#epSearch').placeholder='Find a feed…'; $<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>'; box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
$('#count').textContent=v.title; $('#count').textContent=v.title;
const n=await renderDirectory(v.url,$('#directory')); const n=await renderDirectory(v.url!,$('#directory'));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
return; return;
} }
$('#epSearch').placeholder='Search items…'; $<HTMLInputElement>('#epSearch').placeholder='Search items…';
box.innerHTML=` box.innerHTML=`
<div class="fhead slim"> <div class="fhead slim">
<div class="art">${v.icon}</div> <div class="art">${v.icon}</div>
@@ -287,21 +287,21 @@ async function renderListed(v){
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`; <div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
$('#count').textContent=v.title; $('#count').textContent=v.title;
// The search box looks through these too, title and text, as it does a feed's items (#127). // The search box looks through these too, title and text, as it does a feed's items (#127).
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url,$('#listening',box)); const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`;
} }
/// Currently Listening: episodes you started and have not finished, across every feed you /// Currently Listening: episodes you started and have not finished, across every feed you
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to /// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
/// where you left off, not another way to browse. The one in the player pauses instead. /// where you left off, not another way to browse. The one in the player pauses instead.
async function renderListening(url,box){ async function renderListening(url: string,box: HTMLElement){
let rows=[]; let rows: Entry[]=[];
try{ rows=(await api(url)).entries||[]; }catch{} try{ rows=(await api(url)).entries||[]; }catch{}
box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>` box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
:'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>'; :'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
for(const e of rows){ for(const e of rows){
// Carries its episode, for paintListenRow to repaint as the player moves. // Carries its episode, for paintListenRow to repaint as the player moves.
const el: HTMLDivElement & {entry?: any}=document.createElement('div'); const el: HTMLDivElement & {entry?: Entry}=document.createElement('div');
el.className='childrow'; el.className='childrow';
el.entry=e; el.entry=e;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+ el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
@@ -319,8 +319,8 @@ async function renderListening(url,box){
} }
/// One row's time left, progress and play button, taken from the player when it is the one in it. /// One row's time left, progress and play button, taken from the player when it is the one in it.
function paintListenRow(el){ function paintListenRow(el: HTMLElement & {entry?: Entry}){
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id; const e=el.entry!, now=player.guid===e.guid&&player.feed===e.feed_id;
// Zero until the player has sought to where you left off; the saved position stands till then. // Zero until the player has sought to where you left off; the saved position stands till then.
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime); if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
// The player's own length first: a feed's can be minutes out. // The player's own length first: a feed's can be minutes out.
@@ -335,32 +335,32 @@ function paintListenRow(el){
} }
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes. /// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
function syncListening(){ function syncListening(){
for(const el of $$('#listening .childrow')) for(const el of $$<HTMLElement & {entry?: Entry}>('#listening .childrow'))
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el); if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
} }
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every /// Takes an episode off Currently Listening by forgetting where you got to: the list is every
/// episode with a saved position short of the end, so the position is what has to go. /// episode with a saved position short of the end, so the position is what has to go.
async function forget(e){ async function forget(e: Entry){
// Closed without saving first, or the player's next save would put it straight back. // Closed without saving first, or the player's next save would put it straight back.
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); } if(player.guid===e.guid){ player.guid=null; $<HTMLButtonElement>('#pclose').click(); }
try{ try{
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`, await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
{method:'POST',body:JSON.stringify({secs:0})}); {method:'POST',body:JSON.stringify({secs:0})});
}catch(err){ toast(err.message,true); } }catch(err){ toast(why(err),true); }
if(S.feed===':listening') renderListed(VIEWS[':listening']); if(S.feed===':listening') renderListed(VIEWS[':listening']);
} }
// The toolbar acts on whatever is selected: the feed on the left, the item in the table. // The toolbar acts on whatever is selected: the feed on the left, the item in the table.
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); }; $<HTMLButtonElement>('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); }; $<HTMLButtonElement>('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); }; $<HTMLButtonElement>('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); }; $<HTMLButtonElement>('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
let searchT; let searchT: number;
$('#epSearch').oninput=ev=>{ clearTimeout(searchT); $<HTMLInputElement>('#epSearch').oninput=(ev: Event)=>{ clearTimeout(searchT);
// The Directory looks through what it already has; Currently Listening and the rest ask the // The Directory looks through what it already has; Currently Listening and the rest ask the
// server (#126, #127). // server (#126, #127).
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; searchT=setTimeout(()=>{ S.q=(ev.target as HTMLInputElement).value; S.offset=0;
if(S.feed===':directory'){ dirShow=null; drawDirectory(); } if(S.feed===':directory'){ dirShow=null; drawDirectory(); }
else if(S.feed===':listening') renderListed(VIEWS[':listening']); else if(S.feed===':listening') renderListed(VIEWS[':listening']);
else loadEntries(); },250); }; else loadEntries(); },250); };
@@ -368,13 +368,13 @@ $('#epSearch').oninput=ev=>{ clearTimeout(searchT);
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); }); window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]')); let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
function toggleGroup(id){ function toggleGroup(id: string){
expanded.has(id) ? expanded.delete(id) : expanded.add(id); expanded.has(id) ? expanded.delete(id) : expanded.add(id);
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{} try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
renderFeeds(); renderFeeds();
} }
let globalMax = 3; let globalMax = 3;
function due(ts){ function due(ts: number){
const d = ts - Date.now()/1000; const d = ts - Date.now()/1000;
if(d <= 0) return 'due now'; if(d <= 0) return 'due now';
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m'; if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
@@ -385,7 +385,7 @@ function due(ts){
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does /// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an /// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
/// admin is sent (issue #19); this used to hold them too, shown to admins only. /// admin is sent (issue #19); this used to hold them too, shown to admins only.
function deadSavedText(me){ function deadSavedText(me: Me|null){
const s=me&&me.deadair_saved||0; const s=me&&me.deadair_saved||0;
return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.` return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.`
:'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.'; :'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.';
@@ -429,45 +429,45 @@ async function prefsModal(){
<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>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
$('#gopml').onclick=opmlModal; $<HTMLButtonElement>('#gopml').onclick=opmlModal;
drawTokens(); drawTokens();
$('#tokadd').onclick=async()=>{ $<HTMLButtonElement>('#tokadd').onclick=async()=>{
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; } const name=$<HTMLInputElement>('#tokname').value.trim(); if(!name){ $<HTMLInputElement>('#tokname').focus(); return; }
try{ try{
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})}); const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
$('#tokname').value=''; $<HTMLInputElement>('#tokname').value='';
await drawTokens(); await drawTokens();
// Shown this once: only its hash is kept, so a lost token is revoked and made again. // Shown this once: only its hash is kept, so a lost token is revoked and made again.
$('#tokens').insertAdjacentHTML('afterbegin',`<div class="field" id="toknew"><span class="hint">Your new token, $('#tokens').insertAdjacentHTML('afterbegin',`<div class="field" id="toknew"><span class="hint">Your new token,
${esc(t.name)}. Copy it now: it is not shown again.</span> ${esc(t.name)}. Copy it now: it is not shown again.</span>
<div class="inline"><input type="text" id="tokval" readonly value="${esc(t.token)}"> <div class="inline"><input type="text" id="tokval" readonly value="${esc(t.token)}">
<button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`); <button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`);
$('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy')); $<HTMLButtonElement>('#tokcopy').onclick=()=>copyText(t.token,$<HTMLButtonElement>('#tokcopy'));
$('#tokval').select(); $<HTMLInputElement>('#tokval').select();
}catch(err){ toast(err.message,true); } }catch(err){ toast(why(err),true); }
}; };
$('#sblock').onchange=async e=>{ $<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
const blocked=splitWords(e.target.value); const blocked=splitWords((e.target as HTMLInputElement).value);
try{ try{
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})}); await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
if(S.me) S.me.blocked=blocked; if(S.me) S.me.blocked=blocked;
toast('Saved'); await loadFeeds(true); loadEntries(); toast('Saved'); await loadFeeds(true); loadEntries();
}catch(err){ toast(err.message,true); } }catch(err){ toast(why(err),true); }
}; };
} }
/// Your API tokens, each with when it was made and last used, and a button to revoke it. /// Your API tokens, each with when it was made and last used, and a button to revoke it.
async function drawTokens(){ async function drawTokens(){
const box=$('#tokens'); if(!box) return; const box=$('#tokens'); if(!box) return;
const list=await api('/api/tokens').catch(()=>[]); const list: {id: number, name: string, created: number, last_used: number|null}[]=await api('/api/tokens').catch((): []=>[]);
box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b> box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b>
<span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span> <span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span>
<button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join(''); <button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join('');
for(const b of $$('[data-tok]',box)) b.onclick=async()=>{ for(const b of $$('[data-tok]',box)) b.onclick=async()=>{
try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); } try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); }
catch(err){ toast(err.message,true); } catch(err){ toast(why(err),true); }
}; };
} }
@@ -486,37 +486,37 @@ function chipsHTML(words: string[], input: string){
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`; placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
} }
/// The words in a chip box, with whatever is typed and not yet a chip. /// The words in a chip box, with whatever is typed and not yet a chip.
function chipWords(box){ function chipWords(box: ParentNode){
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)]; return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($<HTMLInputElement>('.chipin',box).value)];
} }
function wireChips(box){ function wireChips(box: HTMLElement){
const input=$('.chipin',box); const input=$<HTMLInputElement>('.chipin',box);
// The hint only in an empty box: beside chips it reads as one more. // The hint only in an empty box: beside chips it reads as one more.
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; }; const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
const add=()=>{ const add=()=>{
const have=$$('.chip',box).map(c=>c.dataset.w.toLowerCase()); const have=$$('.chip',box).map(c=>c.dataset.w!.toLowerCase());
for(const w of splitWords(input.value)){ for(const w of splitWords(input.value)){
if(have.includes(w.toLowerCase())) continue; if(have.includes(w.toLowerCase())) continue;
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase()); input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
} }
input.value=''; hint(); input.value=''; hint();
}; };
input.onkeydown=e=>{ input.onkeydown=(e: KeyboardEvent)=>{
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); } if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); } else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
}; };
// Typed and left without Enter is still meant: Save takes it either way. // Typed and left without Enter is still meant: Save takes it either way.
input.onblur=add; input.onblur=add;
box.onclick=e=>{ box.onclick=(e: MouseEvent)=>{
const x=e.target.closest('.chipx'); const x=(e.target as Element).closest('.chipx');
if(x){ x.parentElement.remove(); hint(); } if(x){ x.parentElement!.remove(); hint(); }
input.focus(); input.focus();
}; };
} }
/// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare /// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
/// box whose blank, number and 0 were explained in bold small print. /// box whose blank, number and 0 were explained in bold small print.
function settingsModal(f, newUrl?: string){ function settingsModal(f: Feed, newUrl?: string){
const kids=S.feeds.filter(c=>c.group===f.id).length; const kids=S.feeds.filter(c=>c.group===f.id).length;
const isGroup=kids>0; const isGroup=kids>0;
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null; const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
@@ -524,7 +524,7 @@ function settingsModal(f, newUrl?: string){
const name=f.title||f.id; const name=f.title||f.id;
// Blank follows the server's default; 0 is every item (#166). // Blank follows the server's default; 0 is every item (#166).
let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest'; let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3); let newest=f.max_new_per_check&&f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
const whose=isGroup const whose=isGroup
? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.` ? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.`
: parent : parent
@@ -576,7 +576,7 @@ function settingsModal(f, newUrl?: string){
// What only matters while it downloads on its own folds away, out of reach, when it does not. // What only matters while it downloads on its own folds away, out of reach, when it does not.
const deps=$('#sdeps'), inner=$('.fdepsin',deps); const deps=$('#sdeps'), inner=$('.fdepsin',deps);
inner.inert=!f.auto_download; inner.inert=!f.auto_download;
$('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; }; $<HTMLInputElement>('#sauto').onchange=(e: Event)=>{ deps.classList.toggle('off',!(e.target as HTMLInputElement).checked); inner.inert=!(e.target as HTMLInputElement).checked; };
// The download limit, said as what it does. A number in a box hid that it is a window on the // The download limit, said as what it does. A number in a box hid that it is a window on the
// newest items, not a pace through the back catalogue (#97). // newest items, not a pace through the back catalogue (#97).
@@ -591,7 +591,7 @@ function settingsModal(f, newUrl?: string){
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}` ? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
: `The server's default: downloads every item${each}, the whole back catalogue.`; : `The server's default: downloads every item${each}, the whole back catalogue.`;
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest; const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
let bars=[]; let bars: HTMLElement[]=[];
const draw=(words: boolean)=>{ const draw=(words: boolean)=>{
// The newest items with a file, as hold_back counts them: one whose files a filter all // The newest items with a file, as hold_back counts them: one whose files a filter all
// skipped holds no place. // skipped holds no place.
@@ -605,21 +605,21 @@ function settingsModal(f, newUrl?: string){
} }
if(!words) return; if(!words) return;
$('#swcap').innerHTML=caption(); $('#swcap').innerHTML=caption();
const n=$('#smax'); const n=$<HTMLInputElement>('#smax');
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); }; if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
}; };
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); }; for(const r of $$<HTMLInputElement>('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand. // Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
$('#sdead').onchange=e=>{ $('#sdpause').disabled=!e.target.checked; }; $<HTMLInputElement>('#sdead').onchange=(e: Event)=>{ $<HTMLInputElement>('#sdpause').disabled=!(e.target as HTMLInputElement).checked; };
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); }; const pause=$<HTMLInputElement>('#sdpause'), pauseText=()=>{ const s=`${Number(pause.value)/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
pause.oninput=pauseText; pauseText(); pause.oninput=pauseText; pauseText();
draw(true); draw(true);
// The feed's own newest items, each as tall as it is long, so the strip reads as this show. // The feed's own newest items, each as tall as it is long, so the strip reads as this show.
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{ if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
const items=page?.entries||[]; const items: Entry[]=page?.entries||[];
const strip=$('#sepwin'); if(!items.length||!strip) return; const strip=$('#sepwin'); if(!items.length||!strip) return;
const longest=Math.max(1,...items.map(e=>e.duration||0)); const longest=Math.max(1,...items.map(e=>e.duration||0));
strip.innerHTML=items.map((e,i)=>{ strip.innerHTML=items.map((e: Entry,i: number)=>{
const file=e.enclosures.some(x=>x.state!=='skipped'); const file=e.enclosures.some(x=>x.state!=='skipped');
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60; const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`; return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
@@ -634,65 +634,65 @@ function settingsModal(f, newUrl?: string){
if(x<=b.getBoundingClientRect().right) break; if(x<=b.getBoundingClientRect().right) break;
} }
newest=Math.max(1,n); newest=Math.max(1,n);
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); } if(mode!=='newest'){ mode='newest'; $<HTMLInputElement>('[name=swin][value=newest]').checked=true; draw(true); }
else { $('#smax').value=String(newest); draw(false); } else { $<HTMLInputElement>('#smax').value=String(newest); draw(false); }
}; };
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); }; strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); }; strip.onpointermove=(e: PointerEvent)=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
}).catch(()=>{}); }).catch(()=>{});
wireChips($('#skw')); wireChips($('#skw'));
if($('#sblock')) wireChips($('#sblock')); if($('#sblock')) wireChips($('#sblock'));
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); $<HTMLButtonElement>('#scopy').onclick=()=>copyText($<HTMLInputElement>('#surl').value,$<HTMLButtonElement>('#scopy'));
// Opened to follow a feed that moved: the new address is what there is to look at. // Opened to follow a feed that moved: the new address is what there is to look at.
if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); } if(newUrl){ $<HTMLInputElement>('#surl').scrollIntoView({block:'center'}); $<HTMLInputElement>('#surl').focus(); }
$('#ssave').onclick=async()=>{ $<HTMLButtonElement>('#ssave').onclick=async()=>{
try{ try{
const patch: Record<string, unknown>={ const patch: Record<string, unknown>={
keywords:chipWords($('#skw')), keywords:chipWords($('#skw')),
max_new_per_check:mode==='default'?null:mode==='all'?0:newest, max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked, auto_download:$<HTMLInputElement>('#sauto').checked, allow_explicit:$<HTMLInputElement>('#sexp').checked, deadair:$<HTMLInputElement>('#sdead').checked,
deadair_pause:Number($('#sdpause').value)}; deadair_pause:Number($<HTMLInputElement>('#sdpause').value)};
if($('#sblock')) patch.blocked=chipWords($('#sblock')); if($('#sblock')) patch.blocked=chipWords($('#sblock'));
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it. // Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
const priv=$('#sprivate'); const priv=$<HTMLInputElement>('#sprivate');
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked; if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
// The address is the same for everyone: an admin's to change, for a feed that moved, and the // The address is the same for everyone: an admin's to change, for a feed that moved, and the
// API refuses it from anyone else. The folder and category are not this dialog's at all. // API refuses it from anyone else. The folder and category are not this dialog's at all.
if(admin) patch.url=$('#surl').value.trim(); if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)}); await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
closeModal(); toast('Saved. It applies from the next check.'); closeModal(); toast('Saved. It applies from the next check.');
await loadFeeds(true); renderFeed(); loadEntries(); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); } }catch(e){ toast(why(e),true); }
}; };
} }
function downloadLatestModal(f){ function downloadLatestModal(f: Feed){
openModal(`<h3>Download latest items</h3> openModal(`<h3>Download latest items</h3>
<div class="field"><label>How many of the newest undownloaded items?</label> <div class="field"><label>How many of the newest undownloaded items?</label>
<input type="number" id="dcount" min="1" max="100" value="5"> <input type="number" id="dcount" min="1" max="100" value="5">
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div> <span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`); <button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
$('#dgo').onclick=async()=>{ $<HTMLButtonElement>('#dgo').onclick=async()=>{
const n=Number($('#dcount').value)||5; const n=Number($<HTMLInputElement>('#dcount').value)||5;
try{ try{
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`, const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
{method:'POST',body:JSON.stringify({count:n})}); {method:'POST',body:JSON.stringify({count:n})});
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`); closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
}catch(e){ toast(e.message,true); } }catch(e){ toast(why(e),true); }
}; };
} }
function removeFeed(f){ function removeFeed(f: Feed){
openModal(`<h3>Unsubscribe?</h3> openModal(`<h3>Unsubscribe?</h3>
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else <p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
reading it keeps it, along with their own read state. reading it keeps it, along with their own read state.
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p> Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`); <button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
$('#rgo').onclick=async()=>{ $<HTMLButtonElement>('#rgo').onclick=async()=>{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'}); await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
closeModal(); toast('Unsubscribed'); S.feed=null; closeModal(); toast('Unsubscribed'); S.feed=null;
await loadFeeds(); if(!S.feeds.length) renderFeed(); await loadFeeds(); if(!S.feeds.length) renderFeed();
@@ -711,11 +711,11 @@ function opmlModal(){
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div> <textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`); <button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
$('#oimp').onclick=async()=>{ $<HTMLButtonElement>('#oimp').onclick=async()=>{
// A chosen file is read here and sent as text, so the server never stores it. Clearing // A chosen file is read here and sent as text, so the server never stores it. Clearing
// the picker lets go of it on this side too, whether it was refused or imported. // the picker lets go of it on this side too, whether it was refused or imported.
const pick=$('#opmlFile'), file=pick.files[0]; const pick=$<HTMLInputElement>('#opmlFile'), file=pick.files![0];
const xml=file ? await file.text() : $('#opmlText').value; const xml=file ? await file.text() : $<HTMLTextAreaElement>('#opmlText').value;
const letGo=()=>{ pick.value=''; }; const letGo=()=>{ pick.value=''; };
// A quick look before sending anything. The server parses it properly and has the last word. // A quick look before sending anything. The server parses it properly and has the last word.
if(!/<opml[\s>]/i.test(xml)){ if(!/<opml[\s>]/i.test(xml)){
@@ -724,17 +724,21 @@ function opmlModal(){
try{ try{
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})}); const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true); letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
}catch(e){ letGo(); toast(e.message,true); } }catch(e){ letGo(); toast(why(e),true); }
}; };
} }
// No toast: the spinners on the rows being checked say it (issue #37). // No toast: the spinners on the rows being checked say it (issue #37).
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } async function scanAll(){
$('#scanAll').onclick=scanAll; spinStart(null);
$('#prefs').onclick=prefsModal; try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
catch(e){ spinEnd(); throw e; }
}
$<HTMLButtonElement>('#scanAll').onclick=scanAll;
$<HTMLButtonElement>('#prefs').onclick=prefsModal;
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while // Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
// the proxy still vouches for them. /api/me says where, when that is the case. // the proxy still vouches for them. /api/me says where, when that is the case.
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; }; $<HTMLButtonElement>('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
api('/api/me').then(u=>{ api('/api/me').then(u=>{
S.me=u; S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':''); $('#who').textContent=u.name+(u.admin?' · admin':'');
@@ -746,18 +750,18 @@ api('/api/me').then(u=>{
// A browser with no theme of its own takes the one the account kept from before, once. // A browser with no theme of its own takes the one the account kept from before, once.
if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true); if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true);
}).catch(()=>{}); }).catch(()=>{});
$('#feedFilter').oninput=renderFeeds; $<HTMLInputElement>('#feedFilter').oninput=renderFeeds;
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a // The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too. // folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
$('#feedlist').onkeydown=ev=>{ $('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
const row=ev.target.closest('[data-id]'); if(!row) return; const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
const id=row.dataset.id; const id=row.dataset.id!;
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click(); if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
else if(row.classList.contains('group')&& else if(row.classList.contains('group')&&
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id); (ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
else return; else return;
ev.preventDefault(); ev.stopPropagation(); ev.preventDefault(); ev.stopPropagation();
}; };
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open')); $<HTMLButtonElement>('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false); $('#scrim').onclick=()=>nav(false);

View File

@@ -1,10 +1,10 @@
/* ---------------- live events ---------------- */ /* ---------------- live events ---------------- */
let sse; let sse: EventSource;
/// The feed list and the open list, taken again from the server. The events keep both current, /// The feed list and the open list, taken again from the server. The events keep both current,
/// and one missed is a count wrong until a reload: reading an item sends none to your other /// and one missed is a count wrong until a reload: reading an item sends none to your other
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while /// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
/// the app is in the background, when everything is missed (#168). /// the app is in the background, when everything is missed (#168).
const catchUp=(()=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(()=>{ const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
loadFeeds(true).catch(()=>{}); loadFeeds(true).catch(()=>{});
if(S.feed) loadEntries().catch(()=>{}); if(S.feed) loadEntries().catch(()=>{});
},300); }; })(); },300); }; })();
@@ -14,11 +14,11 @@ function connect(){
sse=new EventSource('/api/events'); sse=new EventSource('/api/events');
// Back after a drop: whatever was sent meanwhile went to nobody. // Back after a drop: whatever was sent meanwhile went to nobody.
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } }; sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219). // Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); }); const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh. // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const mine=id=>S.feeds.some(f=>f.id===id); const mine=(id: string)=>S.feeds.some(f=>f.id===id);
sse.onmessage=m=>{ sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return } let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){ if(ev.ev==='progress'){
@@ -26,7 +26,7 @@ function connect(){
// Only the row actually downloading. Without the enclosure id this used to paint // Only the row actually downloading. Without the enclosure id this used to paint
// every pending bar at once, so adding a feed looked like it was fetching the lot. // every pending bar at once, so adding a feed looked like it was fetching the lot.
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`); const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); } if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); }
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`); $('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
} }
else if(ev.ev==='download_done'){ else if(ev.ev==='download_done'){
@@ -66,7 +66,9 @@ function connect(){
// every open page reloaded the whole list each time (#103). // every open page reloaded the whole list each time (#103).
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the // Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
// page reloads; reload the list here when a scan synced an OPML if that ever matters. // page reloads; reload the list here when a scan synced an OPML if that ever matters.
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); } else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); spinEnd(ev); if(ev.feeds) refreshEntries(); }
// The worker's job failed, so the scan someone is waiting on will send no scan_done.
else if(ev.ev==='error') spinEnd();
}; };
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); }; sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
} }

View File

@@ -3,16 +3,16 @@ function renderFeed(){
paintScanning(); // another feed's page may be the one open now paintScanning(); // another feed's page may be the one open now
const box=$('#content'); const box=$('#content');
box.classList.remove('plain'); box.classList.remove('plain');
const v=VIEWS[S.feed]; const v=VIEWS[S.feed ?? ''];
if(v&&v.url) return renderListed(v); if(v&&v.url) return renderListed(v);
const f=v?null:S.feeds.find(x=>x.id===S.feed); const f=v?null:S.feeds.find(x=>x.id===S.feed);
$('#tbRemove').disabled=!f; $<HTMLButtonElement>('#tbRemove').disabled=!f;
syncTools(null); syncTools(null);
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; } if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
const name=f?(f.title||f.id):v.title; const name=f?(f.title||f.id):v.title;
$('#epSearch').placeholder=`Search ${name}…`; $<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
const kids=f?S.feeds.filter(c=>c.group===f.id):[]; const kids=f?S.feeds.filter(c=>c.group===f.id):[];
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; } if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0); const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
box.innerHTML = (f ? ` box.innerHTML = (f ? `
<div class="fhead slim"> <div class="fhead slim">
@@ -61,7 +61,7 @@ function renderFeed(){
if(f) pane.className='one'; if(f) pane.className='one';
pane.innerHTML='<div id="list">'+sortHead()+ pane.innerHTML='<div id="list">'+sortHead()+
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>'; '<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort)); $$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
box.appendChild(pane); box.appendChild(pane);
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%'); pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
dragSplit(pane); dragSplit(pane);
@@ -69,9 +69,9 @@ function renderFeed(){
S.sel=null; S.sel=null;
showDetail(null); showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a)); $$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):allAction(b.dataset.a!));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{ $$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f; S.offset=0; S.filter=b.dataset.f!; S.offset=0;
try{ localStorage.setItem('ipx.filter',S.filter); }catch{} try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
renderFeed(); loadEntries(); renderFeed(); loadEntries();
}); });
@@ -89,7 +89,7 @@ function sortHead(){
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`; `${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
}).join('')+'<span></span></div>'; }).join('')+'<span></span></div>';
} }
function sortBy(col){ function sortBy(col: string){
const first=['published','size','kept'].includes(col)?'desc':'asc'; const first=['published','size','kept'].includes(col)?'desc':'asc';
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first}; S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{} try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
@@ -99,9 +99,9 @@ function sortBy(col){
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps /// An OPML subscription's page lists the feeds inside it rather than items, but keeps
/// every action a normal feed has -- it is still an ordinary feed entry underneath. /// every action a normal feed has -- it is still an ordinary feed entry underneath.
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is. // A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
const isPatreon=f=>/patreon\.com\//.test(f&&f.url||''); const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
function renderGroup(f,kids){ function renderGroup(f: Feed,kids: Feed[]){
const unread=kids.reduce((n,c)=>n+c.unread,0); const unread=kids.reduce((n,c)=>n+c.unread,0);
const saved=kids.reduce((n,c)=>n+c.downloaded,0); const saved=kids.reduce((n,c)=>n+c.downloaded,0);
const gone=kids.filter(c=>c.orphaned).length; const gone=kids.filter(c=>c.orphaned).length;
@@ -132,10 +132,10 @@ function renderGroup(f,kids){
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span> <span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
</div> </div>
<div class="childlist" id="kidlist"></div>`; <div class="childlist" id="kidlist"></div>`;
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f)); $$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a!,f));
wireFailBanner($('#content'),f); wireFailBanner($('#content'),f);
const draw=()=>{ const draw=()=>{
const q=($('#kidSearch').value||'').trim().toLowerCase(); const q=($<HTMLInputElement>('#kidSearch').value||'').trim().toLowerCase();
const box=$('#kidlist'); box.innerHTML=''; const box=$('#kidlist'); box.innerHTML='';
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst); const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; } if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
@@ -154,26 +154,30 @@ function renderGroup(f,kids){
box.appendChild(el); box.appendChild(el);
} }
}; };
$('#kidSearch').oninput=draw; $<HTMLInputElement>('#kidSearch').oninput=draw;
draw(); draw();
} }
async function feedAction(a,f){ async function feedAction(a: string,f: Feed){
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } if(a==='scan'){
spinStart(f.id);
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
catch(e){ spinEnd(); throw e; }
}
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); } if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f); if(a==='rm') removeFeed(f);
if(a==='pin'){ if(a==='pin'){
try{ try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})}); await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
await loadFeeds(true); renderFeed(); await loadFeeds(true); renderFeed();
}catch(e){ toast(e.message,true); } }catch(e){ toast(why(e),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==='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.
async function allAction(a){ async function allAction(a: string){
if(a==='scanall') return scanAll(); if(a==='scanall') return scanAll();
if(a==='readall'){ if(a==='readall'){
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0); const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
@@ -183,7 +187,7 @@ async function allAction(a){
try{ try{
const r=await api('/api/read-all',{method:'POST'}); const r=await api('/api/read-all',{method:'POST'});
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); } }catch(e){ toast(why(e),true); }
} }
} }

View File

@@ -2,7 +2,7 @@
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just /// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each. /// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
let rowsQueued=0; let rowsQueued=0;
function patchFeed(row){ function patchFeed(row: Feed){
const i=S.feeds.findIndex(f=>f.id===row.id); const i=S.feeds.findIndex(f=>f.id===row.id);
if(i<0) S.feeds.push(row); else S.feeds[i]=row; if(i<0) S.feeds.push(row); else S.feeds[i]=row;
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); }); if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
@@ -19,58 +19,75 @@ async function loadFeeds(keepSel?: boolean){
// brand new account) leaves S.feed alone, so the empty state's own message shows instead. // brand new account) leaves S.feed alone, so the empty state's own message shows instead.
if(!keepSel && S.feeds.length){ if(!keepSel && S.feeds.length){
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed)); const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
selectFeed(known ? S.feed : ':all'); selectFeed(known ? S.feed! : ':all');
} }
} }
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the // An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
// server's alphabetical order still holds within each half. // server's alphabetical order still holds within each half.
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0); const unreadFirst=(a: Feed,b: Feed)=>Number(b.unread>0)-Number(a.unread>0);
// The original's source list opened with these, above the feeds. They are places, not feeds: // The original's source list opened with these, above the feeds. They are places, not feeds:
// an id starting with ':' can never be a feed's, since feed ids are slugs. // an id starting with ':' can never be a feed's, since feed ids are slugs.
const VIEWS={ const VIEWS: Record<string,{title: string, icon: string, url?: string, blurb?: string}>={
// The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124). // The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124).
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'}, ':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'},
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50', ':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'}, blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all}, ':all':{title:'All Subscriptions',icon:ICON.all},
}; };
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder /// Feeds being checked right now, from the event stream, and since when: their rows, and the row
/// holding one, carry a spinner. /// of a folder holding one, carry a spinner.
const scanning=new Set<string>(); const scanning=new Map<string, number>();
/// A row's spinner takes the place of its unread count, so it waits this long: most feeds are
/// checked in a few milliseconds, and each count blinked out and back as its feed went by.
const SPIN_AFTER=300;
function setScanning(id: string, on: boolean){ function setScanning(id: string, on: boolean){
if(on) scanning.add(id); else scanning.delete(id); if(on){ scanning.set(id,Date.now()); setTimeout(paintScanning,SPIN_AFTER); } else scanning.delete(id);
paintScanning(); paintScanning();
} }
function paintScanning(){ function paintScanning(){
const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)!>=SPIN_AFTER;
for(const row of $$('#feedlist .feed')){ for(const row of $$('#feedlist .feed')){
const id=row.dataset.id; const id=row.dataset.id!;
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id)); const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id));
row.classList.toggle('scanning',on); row.classList.toggle('scanning',on);
if(on) row.title='Checking for new items…'; else row.removeAttribute('title'); if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
} }
// The refresh buttons turn while what they check is being checked (#78). On <body>, because // A feed's button turns on its own page only, wherever the person has gone since pressing it.
// a feed's page is redrawn as its items come in and would take a class on the button with it. document.body.classList.toggle('scan-this',spinFeed!==null&&spinFeed===S.feed);
const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id)); }
document.body.classList.toggle('scan-this',busy(S.feed)); /// The refresh buttons turn from the press until that scan's scan_done (#78, #233). On <body>,
document.body.classList.toggle('scan-any',S.feeds.some(f=>scanning.has(f.id))); /// because a feed's page is redrawn as its items come in and would take a class on the button
/// with it. Following the feeds being checked instead, the icon snapped back upright in the gap
/// between one feed and the next, many times a second (#231).
let spinFeed: string|null=null;
function spinStart(feed: string|null){
if(feed===null) document.body.classList.add('scan-any');
else { spinFeed=feed; paintScanning(); }
}
/// `only` and `forced` from scan_done: a scheduled scan, or one limited to another feed, leaves
/// a pressed button turning, since the scan it asked for is still queued behind it. Nothing
/// (an error ended the job) stops them all.
function spinEnd(done?: {only: string|null, forced: boolean}){
if(!done||done.forced&&done.only==null) document.body.classList.remove('scan-any');
if(!done||done.forced&&done.only===spinFeed){ spinFeed=null; paintScanning(); }
} }
function renderFeeds(){ function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase(); const q=$<HTMLInputElement>('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop; const list=$('#feedlist'); const top=list.scrollTop;
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on. // Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]'); const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]');
const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]); const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
const done=()=>{ const done=()=>{
list.scrollTop=top; list.scrollTop=top;
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`); const row=back&&list.querySelector<HTMLElement>(`[data-id="${CSS.escape(back[0])}"]`);
if(row) (back[1]&&$('.chev',row)||row).focus(); if(row) (back[1]&&$('.chev',row)||row as HTMLElement).focus();
}; };
// The images already drawn, to go into the rows that replace theirs. Made anew, every icon in // The images already drawn, to go into the rows that replace theirs. Made anew, every icon in
// the list went blank and loaded again, once a frame through a check of every feed, as each // the list went blank and loaded again, once a frame through a check of every feed, as each
// feed's new row came in (#134). Matched by their markup, so a changed one is made anew. // feed's new row came in (#134). Matched by their markup, so a changed one is made anew.
const drawn=new Map<string,HTMLImageElement[]>(); const drawn=new Map<string,HTMLImageElement[]>();
for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k).push(img); } for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
list.innerHTML=''; list.innerHTML='';
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0); const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
const places=document.createElement('div'); const places=document.createElement('div');
@@ -91,9 +108,9 @@ function renderFeeds(){
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds // Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
// go first: a folder with its feeds under it, a feed from inside one lifted out of it. // go first: a folder with its feeds under it, a feed from inside one lifted out of it.
const byId=Object.fromEntries(shown.map(f=>[f.id,f])); const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned); const inside=(f: Feed)=>shown.filter(c=>c.group===f.id&&!c.pinned);
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group])); const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
const order=[]; const order: [Feed, number][]=[];
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){ for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
order.push([f,0]); order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching // A subscription can hold dozens of feeds, so a folder starts closed. Searching
@@ -110,14 +127,14 @@ function renderFeeds(){
// A subscription holds no entries itself, so its counts are the sum of what is inside -- // A subscription holds no entries itself, so its counts are the sum of what is inside --
// taken from every feed it holds, not just the ones a filter left showing. // taken from every feed it holds, not just the ones a filter left showing.
const mine=S.feeds.filter(c=>c.group===f.id); const mine=S.feeds.filter(c=>c.group===f.id);
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0); const sum=(k: 'unread'|'entries'|'downloaded')=>mine.reduce((n,c)=>n+(c[k]||0),0);
const [unread,eps,saved]=mine.length const [unread,eps,saved]=mine.length
? [sum('unread'),sum('entries'),sum('downloaded')] ? [sum('unread'),sum('entries'),sum('downloaded')]
: [f.unread,f.entries,f.downloaded]; : [f.unread,f.entries,f.downloaded];
// A group's own row has no error of its own worth mentioning if the OPML itself // A group's own row has no error of its own worth mentioning if the OPML itself
// reads fine; it is failing when any feed inside it is. The feed's own page says what // reads fine; it is failing when any feed inside it is. The feed's own page says what
// went wrong (failBannerHTML); the list only has to make it findable. // went wrong (failBannerHTML); the list only has to make it findable.
const bad=c=>c.failing?.reason||c.last_error; const bad=(c: Feed)=>c.failing?.reason||c.last_error;
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f); const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
const nbad=mine.filter(bad).length; const nbad=mine.filter(bad).length;
const el=document.createElement('div'); const el=document.createElement('div');
@@ -143,15 +160,15 @@ function renderFeeds(){
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+ (f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`; `<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); }; el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); } for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
list.appendChild(el); list.appendChild(el);
} }
paintScanning(); paintScanning();
done(); done();
} }
function selectFeed(id){ function selectFeed(id: string){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value=''; S.feed=id; S.offset=0; S.sel=null; S.q=''; $<HTMLInputElement>('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{} try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries(); renderFeeds(); renderFeed(); loadEntries();
} }
@@ -159,15 +176,15 @@ function selectFeed(id){
/// A failing feed's error, in plain words with something to do about it, once `failing` is /// A failing feed's error, in plain words with something to do about it, once `failing` is
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in /// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
/// src/feed.rs). Anything else still shows the raw error, as before. /// src/feed.rs). Anything else still shows the raw error, as before.
function failBannerHTML(f){ function failBannerHTML(f: Feed){
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)} if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${ <button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`; f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`; if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
return ''; return '';
} }
function wireFailBanner(box,f){ function wireFailBanner(box: ParentNode,f: Feed){
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f); const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url); const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing?.new_url??undefined);
} }

View File

@@ -70,7 +70,7 @@ document.addEventListener('touchstart', ev => {
// back button is never swallowed with it. // back button is never swallowed with it.
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault(); 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: false}); }, {passive: false});
document.addEventListener('touchmove', ev => { document.addEventListener('touchmove', ev => {

View File

@@ -6,7 +6,7 @@ async function loadEntries(append?: boolean){
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir}); const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q); if(S.q) p.set('q',S.q);
const asked=performance.now(), seq=++loadSeq; const asked=performance.now(), seq=++loadSeq;
const r=await api(S.feed===':all' ? `/api/entries?${p}` const r: {total: number, entries: Entry[]}=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`); : `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
// Only the latest list counts: marking everything read reloads the All tab, and clicking // Only the latest list counts: marking everything read reloads the All tab, and clicking
// Unread straight after could have that answer land last and replace the Unread one. // Unread straight after could have that answer land last and replace the Unread one.
@@ -14,14 +14,14 @@ async function loadEntries(append?: boolean){
S.total=r.total; S.total=r.total;
for(const e of r.entries){ for(const e of r.entries){
const w=readWrites.get(readKey(e)); const w=readWrites.get(readKey(e));
if(w && !(w.done<asked)) e.read=w.read; if(w && !(w.done!==undefined&&w.done<asked)) e.read=w.read;
} }
let entries = append ? S.entries.concat(r.entries) : r.entries; let entries = append ? S.entries.concat(r.entries) : r.entries;
// A background scan finishing refreshes the list from the server, which -- on the Unread // A background scan finishing refreshes the list from the server, which -- on the Unread
// tab -- would drop the item you have open the moment reading it took it off the filter. // tab -- would drop the item you have open the moment reading it took it off the filter.
// Keep it until you pick a different one; the next refresh after that no longer protects it. // Keep it until you pick a different one; the next refresh after that no longer protects it.
// The items turned past on the way to it are kept too, so a swipe back still finds them. // The items turned past on the way to it are kept too, so a swipe back still finds them.
const kept=e=>e.guid===S.sel||turned.has(e.guid); const kept=(e: Entry)=>e.guid===S.sel||turned.has(e.guid);
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e)); if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
if(!append && S.sel){ if(!append && S.sel){
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid)); const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
@@ -32,7 +32,7 @@ async function loadEntries(append?: boolean){
} }
function renderEntries(){ function renderEntries(){
const box=$('#eps'); if(!box) return; const box=$('#eps'); if(!box) return;
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed]; const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''];
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0); const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
$('#count').textContent= $('#count').textContent=
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`; `${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
@@ -53,7 +53,7 @@ function renderEntries(){
box.appendChild(b); box.appendChild(b);
} }
} }
function epEl(e){ function epEl(e: Entry){
// An item may carry several files. The row summarises the one you would act on -- // An item may carry several files. The row summarises the one you would act on --
// the playable one, else anything already downloaded, else the first -- and says how // the playable one, else anything already downloaded, else the first -- and says how
// many others there are; the pane below lists them all. // many others there are; the pane below lists them all.
@@ -78,7 +78,7 @@ function epEl(e){
num&&`<span>${num}</span>`, num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`, left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`, others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
enc&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`, enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
enc&&enc.deadair_pending&&DEAD_PENDING, enc&&enc.deadair_pending&&DEAD_PENDING,
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`, enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
].filter(Boolean).join('<span class="dot"></span>')}</div> ].filter(Boolean).join('<span class="dot"></span>')}</div>
@@ -97,13 +97,13 @@ function epEl(e){
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''} ${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
</div>`; </div>`;
el.onclick=()=>selectEntry(e); el.onclick=()=>selectEntry(e);
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)}); $$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a!,e,el)});
return el; return el;
} }
/// Whether the browser can play it. Having a file is not the same as being playable: /// Whether the browser can play it. Having a file is not the same as being playable:
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG /// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
/// is just a broken player. /// is just a broken player.
function isPlayable(x){ function isPlayable(x: Enclosure|undefined){
if(!x || !x.path) return false; if(!x || !x.path) return false;
const m=(x.mime||'').toLowerCase(); const m=(x.mime||'').toLowerCase();
if(m.startsWith('audio/')||m.startsWith('video/')) return true; if(m.startsWith('audio/')||m.startsWith('video/')) return true;
@@ -114,7 +114,7 @@ function isPlayable(x){
} }
/// What an enclosure is, for a row that is not media: "image", "pdf", "document". /// What an enclosure is, for a row that is not media: "image", "pdf", "document".
function kindOf(enc){ function kindOf(enc: {mime?: string|null, url?: string}){
const m=(enc.mime||'').toLowerCase(); const m=(enc.mime||'').toLowerCase();
if(m.startsWith('image/')) return 'image'; if(m.startsWith('image/')) return 'image';
if(m.startsWith('video/')) return 'video'; if(m.startsWith('video/')) return 'video';
@@ -128,9 +128,9 @@ function kindOf(enc){
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when /// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon /// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
/// either way keeps the column lined up; the words are in its tooltip. /// either way keeps the column lined up; the words are in its tooltip.
function kindIcon(enc){ function kindIcon(enc: Enclosure){
const k=kindOf(enc); const k=kindOf(enc);
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file; const i=(<Record<string,string>>{audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent})[k]||ICON.file;
const [cls,label]=enc.path ? [' here',`${k}, downloaded`] const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`] : enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
: ['',k]; : ['',k];
@@ -138,7 +138,7 @@ function kindIcon(enc){
} }
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; } function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; }; const feedName=(id: string|null|undefined): string=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):(id??''); };
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional, /// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's /// and Scripting News titles almost none of its posts) the opening of its text, then its file's
@@ -147,24 +147,24 @@ const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached /// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded" /// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
/// read "2010)Recorded". /// read "2010)Recorded".
const plainText=(html?: string)=>html const plainText=(html?: string|null)=>html
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'') ? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
.replace(/\s+/g,' ').trim() : ''; .replace(/\s+/g,' ').trim() : '';
function entryName(e): {text: string, derived: boolean}{ function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
if(e.title&&e.title.trim()) return {text:e.title, derived:false}; if(e.title&&e.title.trim()) return {text:e.title, derived:false};
const words=plainText(e.description); const words=plainText(e.description);
if(words){ if(words){
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words; const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
return {text:cut, derived:true}; return {text:cut, derived:true};
} }
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,''); const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop()!.replace(/\.[a-z0-9]{1,5}$/i,'');
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } } if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true}; return {text:[feedName(e.feed_id), dateOf(e.published??null)].filter(Boolean).join(', '), derived:true};
} }
/// Selecting an item shows it in the pane below, rather than expanding the row. /// Selecting an item shows it in the pane below, rather than expanding the row.
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone. /// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
function swapRow(e){ function swapRow(e: Entry){
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`); const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); } if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
} }
@@ -172,9 +172,9 @@ function swapRow(e){
/// Read and unread as this page last set them, and when the server had it. A list asked for /// Read and unread as this page last set them, and when the server had it. A list asked for
/// before then answers with the old state: it put the dot back on an item just read, until /// before then answers with the old state: it put the dot back on an item just read, until
/// the next refresh took it off again (loadEntries). /// the next refresh took it off again (loadEntries).
const readWrites=new Map(); const readWrites=new Map<string, {read: boolean, done?: number}>();
const readKey=e=>e.feed_id+'\n'+e.guid; const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
function setRead(e,read){ function setRead(e: Entry,read: boolean){
e.read=read; e.read=read;
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w); const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`, return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
@@ -184,7 +184,7 @@ function setRead(e,read){
/// Opening an item is reading it. The row is redrawn where it stands rather than the list /// Opening an item is reading it. The row is redrawn where it stands rather than the list
/// reloaded, so an item does not vanish from under the pointer on the Unread tab. /// reloaded, so an item does not vanish from under the pointer on the Unread tab.
function markRead(e){ function markRead(e: Entry){
if(e.read) return; if(e.read) return;
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); }); setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
} }
@@ -193,7 +193,7 @@ function markRead(e){
/// in the list until the reader closes or another item is picked from the list. Dropped as each /// in the list until the reader closes or another item is picked from the list. Dropped as each
/// was left, a swipe back had nothing to go back to: the item just read was already gone. /// was left, a swipe back had nothing to go back to: the item just read was already gone.
const turned=new Set<string>(); const turned=new Set<string>();
function dropTurned(keep){ function dropTurned(keep: string|null){
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep); const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
turned.clear(); turned.clear();
if(!gone.length) return; if(!gone.length) return;
@@ -201,7 +201,7 @@ function dropTurned(keep){
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove(); for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
} }
function selectEntry(e,turning=false){ function selectEntry(e: Entry,turning=false){
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh // On the Unread tab the item you were reading goes as you move on, not whenever a refresh
// next happens to come along, which left a few read ones in the list for a while. // next happens to come along, which left a few read ones in the list for a while.
if(S.filter==='unread' && S.sel && S.sel!==e.guid){ if(S.filter==='unread' && S.sel && S.sel!==e.guid){
@@ -217,10 +217,10 @@ function selectEntry(e,turning=false){
} }
/// Drag the divider between the item list and the item text. /// Drag the divider between the item list and the item text.
function dragSplit(pane){ function dragSplit(pane: HTMLElement){
const grab=$('#grab',pane); const grab=$('#grab',pane);
if(!grab) return; if(!grab) return;
const move=ev=>{ const move=(ev: MouseEvent)=>{
const box=pane.getBoundingClientRect(); const box=pane.getBoundingClientRect();
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100)); const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
pane.style.setProperty('--listh',pct.toFixed(1)+'%'); pane.style.setProperty('--listh',pct.toFixed(1)+'%');
@@ -231,7 +231,7 @@ function dragSplit(pane){
document.body.style.userSelect=''; document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{} try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
}; };
grab.onmousedown=ev=>{ grab.onmousedown=(ev: MouseEvent)=>{
ev.preventDefault(); ev.preventDefault();
document.body.style.userSelect='none'; document.body.style.userSelect='none';
document.addEventListener('mousemove',move); document.addEventListener('mousemove',move);
@@ -243,24 +243,24 @@ function dragSplit(pane){
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick // ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
// the first. Key rows by feed as well if that ever happens. // the first. Key rows by feed as well if that ever happens.
const cur=()=>S.entries.find(x=>x.guid===S.sel); const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){ function syncTools(e: Entry|null){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable)); $<HTMLButtonElement>('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e; $<HTMLButtonElement>('#tbRead').disabled=$<HTMLButtonElement>('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree. Each // The same icons as the item's own buttons beside its title, so the two never disagree. Each
// shows what is, as the pin always did: read showed what a click would do, so the two side by // shows what is, as the pin always did: read showed what a click would do, so the two side by
// side said opposite things (#74). The shape carries it, not the colour. // side said opposite things (#74). The shape carries it, not the colour.
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check; $<HTMLButtonElement>('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read))); $<HTMLButtonElement>('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged))); $<HTMLButtonElement>('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin; $<HTMLButtonElement>('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
if(e){ if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`; $<HTMLButtonElement>('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted'; $<HTMLButtonElement>('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
} }
} }
/// The item's text in the pane below the list, and its files in the pane beside it. /// The item's text in the pane below the list, and its files in the pane beside it.
function showDetail(e){ function showDetail(e: Entry|null){
const box=$('#detail'), files=$('#files'); if(!box) return; const box=$('#detail'), files=$('#files'); if(!box) return;
// A pane that can only say "No files" gives its width to the list instead. // A pane that can only say "No files" gives its width to the list instead.
if(files) files.hidden=!(e&&e.enclosures.length); if(files) files.hidden=!(e&&e.enclosures.length);
@@ -278,15 +278,15 @@ function showDetail(e){
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches; const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
box.innerHTML=detailHtml(e); box.innerHTML=detailHtml(e);
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`; if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
$('#dback').onclick=()=>showDetail(null); $<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=> 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)); b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
syncPlayButtons(); syncPlayButtons();
} }
/// What the reader shows for an item. Its own function because a phone also draws the next or /// 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). /// previous item beside the reader while it is swiped (gestures.ts).
function detailHtml(e){ function detailHtml(e: Entry){
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
@@ -317,7 +317,7 @@ function detailHtml(e){
} }
/// 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.
function encBox(x){ function encBox(x: Enclosure){
const size=x.length?mb(x.length):''; const size=x.length?mb(x.length):'';
// One file serves everyone reading the feed, so deleting is not a private act. // One file serves everyone reading the feed, so deleting is not a private act.
const f=S.feeds.find(y=>y.id===x.feed_id); const f=S.feeds.find(y=>y.id===x.feed_id);
@@ -328,8 +328,9 @@ 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>`;
// Played without its dead air, so saved without it too (#186). // Played without its dead air, so saved without it too (#186).
const saveLabel=x.deadair>0?'Save to this computer, without its dead air':'Save to this computer'; const dead=(x.deadair??0)>0;
const saveBtn=`<a class="btn ico" href="/media/${x.id}${x.deadair>0?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`; const saveLabel=dead?'Save to this computer, without its dead air':'Save to this computer';
const saveBtn=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
// The publisher's address, not this server's copy, which only someone signed in here can open. // 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>`; 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)){
@@ -369,14 +370,14 @@ function encBox(x){
</div>`; </div>`;
} }
async function epAction(a: string, e, el, encId?: number){ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
// Swap the row in place and refresh the text below when it is the one being read. // Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); }; const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0]; const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
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(entryName(e).text, encId!=null ? enc.url : e.link, el); if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
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'){
@@ -396,12 +397,12 @@ async function epAction(a: string, e, el, encId?: number){
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'}); await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
}catch(err){ }catch(err){
// 409: somebody else has it starred or unplayed. Their reason, their words. // 409: somebody else has it starred or unplayed. Their reason, their words.
if(!/one copy of this file/.test(err.message)) throw err; if(!/one copy of this file/.test(why(err))) throw err;
if(!confirm(err.message+'\n\nDelete it anyway?')) return; if(!confirm(why(err)+'\n\nDelete it anyway?')) return;
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'}); await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
} }
toast('Deleted'); loadEntries(); loadFeeds(true); toast('Deleted'); loadEntries(); loadFeeds(true);
} }
}catch(err){ toast(err.message,true); } }catch(err){ toast(why(err),true); }
} }

View File

@@ -1,6 +1,6 @@
document.getElementById('f').onsubmit = async e => { document.getElementById('f')!.onsubmit = async e => {
e.preventDefault(); e.preventDefault();
const msg = document.getElementById('msg'); const msg = document.getElementById('msg')!;
msg.textContent = ''; msg.textContent = '';
const r = await fetch('/api/login', { const r = await fetch('/api/login', {
method: 'POST', method: 'POST',

View File

@@ -1,11 +1,11 @@
/* ---------------- player ---------------- */ /* ---------------- player ---------------- */
const audio=$('#audio'); const audio=$<HTMLAudioElement>('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean, const player: {guid: string|null, feed: string|null, entry: Entry|null, enc?: number, src?: string, moved?: boolean,
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false}; saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
/// Where a file plays from: DeadAir's playlist when the server says this person plays it /// Where a file plays from: DeadAir's playlist when the server says this person plays it
/// shortened (#171, #181), whose times the entry is already given in, else the file itself. /// shortened (#171, #181), whose times the entry is already given in, else the file itself.
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`; const mediaSrc=(enc: {id: number, deadair?: number|null})=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it /// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails /// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
return ready.then(()=>{ return ready.then(()=>{
// Its playlist and audio requests join the trace of the tap that played them (#192). // Its playlist and audio requests join the trace of the tap that played them (#192).
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())}); hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal){ toast('Playback failed: '+d.details,true); hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } }); report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
hls.loadSource(url); hls.attachMedia(audio); hls.loadSource(url); hls.attachMedia(audio);
}); });
@@ -49,8 +49,8 @@ function markPlayed(){
} }
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one. /// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
function play(e,enc=e.enclosures.find(isPlayable)){ function play(e: Entry,enc=e.enclosures.find(isPlayable)){
if(!isPlayable(enc)){ if(!enc||!isPlayable(enc)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true); toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
return; return;
} }
@@ -76,17 +76,17 @@ function play(e,enc=e.enclosures.find(isPlayable)){
player.marked=false; player.marked=false;
paintDead(); paintDead();
} }
const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true)); const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true));
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go(); if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
renderEntries(); renderEntries();
} }
function mediaSession(e,f){ function mediaSession(e: Entry,f: Feed|undefined){
if(!('mediaSession' in navigator)) return; if(!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata=new MediaMetadata({ navigator.mediaSession.metadata=new MediaMetadata({
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'', title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[], artwork:(e.image||(f&&f.image))?[{src:artSrc((e.image||f?.image)!),sizes:'512x512'}]:[],
}); });
const h={play:()=>audio.play(),pause:()=>audio.pause(), const h: Record<string,()=>void>={play:()=>audio.play(),pause:()=>audio.pause(),
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30}; seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} } for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
} }
@@ -94,7 +94,7 @@ audio.addEventListener('timeupdate',()=>{
const d=audio.duration||player.entry?.duration||0; const d=audio.duration||player.entry?.duration||0;
$('#pcur').textContent=clock(audio.currentTime); $('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d); $('#pdur').textContent=clock(d);
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000)); if(d) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000));
// Only playing counts as moving: the seek to where you left off happens paused, and saving // Only playing counts as moving: the seek to where you left off happens paused, and saving
// that would write back whatever the list said, however old. // that would write back whatever the list said, however old.
if(!audio.paused) player.moved=true; if(!audio.paused) player.moved=true;
@@ -117,29 +117,29 @@ function savePos(){
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null; const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
navigator.sendBeacon?.( navigator.sendBeacon?.(
// A beacon sends no headers, so the trace goes in the address (#192). // A beacon sends no headers, so the trace goes in the address (#192).
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position?traceparent=${traceparent()}`, `/api/entries/${encodeURIComponent(player.feed!)}/${encodeURIComponent(player.guid!)}/position?traceparent=${traceparent()}`,
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'})); new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
} }
audio.addEventListener('pause',savePos); audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play}); audio.addEventListener('ended',()=>{savePos();markPlayed();$<HTMLButtonElement>('#pplay').innerHTML=ICON.play});
// body.playing is what sets the EQ bars moving. // body.playing is what sets the EQ bars moving.
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); }); audio.addEventListener('play',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); }); audio.addEventListener('pause',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons); for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons);
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files /// Every play button for what is playing shows pause, like the player bar's: a row's, the files
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played. /// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
function syncPlayButtons(){ function syncPlayButtons(){
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc); const on=(guid: string|null,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
const paint=(b,now,idle)=>{ const paint=(b: HTMLElement,now: boolean,idle: string)=>{
const label=now?'Pause':idle; const label=now?'Pause':idle;
if(b.title===label) return; if(b.title===label) return;
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play; b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
}; };
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep').dataset.guid),'Play'); for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest<HTMLElement>('.ep')!.dataset.guid!),'Play');
for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]')) for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
paint(b,on(S.sel,Number(b.dataset.enc)),'Play'); paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item'); const e=cur(); paint($<HTMLButtonElement>('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
} }
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening); for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
window.addEventListener('beforeunload',savePos); window.addEventListener('beforeunload',savePos);
@@ -157,27 +157,27 @@ audio.addEventListener('playing',()=>{
asked=0; waited=0; asked=0; waited=0;
}); });
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); }); audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause(); $<HTMLButtonElement>('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15; $<HTMLButtonElement>('#pback').onclick=()=>audio.currentTime-=15;
$('#pfwd').onclick=()=>audio.currentTime+=30; $<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000}; $<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)}; $<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)}; $<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the /// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
/// icon's shape and aria-pressed, never a colour. /// icon's shape and aria-pressed, never a colour.
function paintDead(){ function paintDead(){
const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed); const b=$<HTMLButtonElement>('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
b.hidden=!f||document.body.classList.contains('has-video'); b.hidden=!f||document.body.classList.contains('has-video');
if(b.hidden) return; if(!f||b.hidden) return;
b.setAttribute('aria-pressed',String(!!f.deadair)); b.setAttribute('aria-pressed',String(!!f.deadair));
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair; b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show'; b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
} }
$('#pdead').onclick=async()=>{ $<HTMLButtonElement>('#pdead').onclick=async()=>{
const f=S.feeds.find(x=>x.id===player.feed); if(!f) return; const f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
const on=!f.deadair; const on=!f.deadair;
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); } try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
catch(err){ toast(err.message,true); return; } catch(err){ toast(why(err),true); return; }
f.deadair=on; paintDead(); f.deadair=on; paintDead();
const switched=await switchVersion(); const switched=await switchVersion();
toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show' toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show'
@@ -188,14 +188,14 @@ $('#pdead').onclick=async()=>{
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time. /// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
async function switchVersion(){ async function switchVersion(){
if(!player.guid) return false; if(!player.guid) return false;
const [feed,guid]=[player.feed,player.guid].map(encodeURIComponent); const [feed,guid]=[player.feed!,player.guid!].map(s=>encodeURIComponent(s));
// Saved and waited for, not beaconed, so the entry asked for next has this place in it. // Saved and waited for, not beaconed, so the entry asked for next has this place in it.
if(audio.readyState){ if(audio.readyState){
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({ await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{}); secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
player.moved=false; player.moved=false;
} }
const e=await api(`/api/entries/${feed}/${guid}`).catch(()=>null); const e: Entry|null=await api(`/api/entries/${feed}/${guid}`).catch((): null=>null);
const enc=e&&e.enclosures.find(x=>x.id===player.enc); const enc=e&&e.enclosures.find(x=>x.id===player.enc);
if(!enc||mediaSrc(enc)===player.src) return false; if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused; const was=!audio.paused;
@@ -205,13 +205,13 @@ async function switchVersion(){
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{}); if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
return true; return true;
} }
$('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries(); $<HTMLButtonElement>('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
// Called here, not left to the pause event: closing a player already paused fires none. // Called here, not left to the pause event: closing a player already paused fires none.
syncListening()}; syncListening()};
(function restore(){ (function restore(){
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol'); const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
if(r){$('#rate').value=r;audio.playbackRate=+r} if(r){$<HTMLSelectElement>('#rate').value=r;audio.playbackRate=+r}
if(v){$('#vol').value=v;audio.volume=Number(v)/100} if(v){$<HTMLInputElement>('#vol').value=v;audio.volume=Number(v)/100}
})(); })();
document.addEventListener('keydown',ev=>{ document.addEventListener('keydown',ev=>{
@@ -223,34 +223,34 @@ document.addEventListener('keydown',ev=>{
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()} if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15} else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30} else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()} else if(ev.key==='/'){ev.preventDefault();$<HTMLInputElement>('#epSearch')?.focus()}
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev); else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
}); });
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a // Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's // letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
// own shortcuts still work, or while a dialog is open. // own shortcuts still work, or while a dialog is open.
const GO={a:':all',d:':directory',l:':listening'}; const GO: Record<string,string>={a:':all',d:':directory',l:':listening'};
let gAt=0; let gAt=0;
function stepEntry(by){ function stepEntry(by: number){
if(VIEWS[S.feed]?.url||!S.entries.length) return; if(VIEWS[S.feed ?? '']?.url||!S.entries.length) return;
const i=S.entries.findIndex(x=>x.guid===S.sel); const i=S.entries.findIndex(x=>x.guid===S.sel);
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))]; const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
selectEntry(e,true); selectEntry(e,true);
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'}); $(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
} }
function stepFeed(by){ function stepFeed(by: number){
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id; const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
if(!id) return; if(!id) return;
selectFeed(id); selectFeed(id);
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'}); $(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
} }
const KEYS={ const KEYS: Record<string,()=>void>={
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1), j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
J:()=>stepFeed(1), K:()=>stepFeed(-1), J:()=>stepFeed(1), K:()=>stepFeed(-1),
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while // The toolbar's own buttons, so a key does exactly what the click does, and nothing while
// they are disabled. // they are disabled.
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(), o:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(),
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); }, v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(), A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } }, r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
@@ -259,7 +259,7 @@ const KEYS={
'?':()=>keysModal(), '?':()=>keysModal(),
g:()=>{ gAt=Date.now(); }, g:()=>{ gAt=Date.now(); },
}; };
function typed(ev){ function typed(ev: KeyboardEvent){
// The second key of a g pair counts only if it follows within a second and a half. // The second key of a g pair counts only if it follows within a second and a half.
const pair=Date.now()-gAt<1500; gAt=0; const pair=Date.now()-gAt<1500; gAt=0;
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key]; const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
@@ -268,7 +268,7 @@ function typed(ev){
} }
/// What ? shows: every key, grouped as Feedly's own list is. /// What ? shows: every key, grouped as Feedly's own list is.
function keysModal(){ function keysModal(){
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c); const k=(s: string)=>`<kbd>${esc(s)}</kbd>`, g=(c: string)=>k('g')+' '+k(c);
const rows=[ const rows=[
['Go to'], ['Go to'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'], [g('a'),'All Subscriptions'],[g('d'),'Directory'],

View File

@@ -37,13 +37,13 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
root.dataset.mode = !both ? 'light' root.dataset.mode = !both ? 'light'
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode; : theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
// The tab's icon in the same variant as the logo on the page. // The tab's icon in the same variant as the logo on the page.
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode]; const fav = $<HTMLLinkElement>('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode!]!;
// The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's // The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's
// toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself. // toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself.
const tc = $('meta[name=theme-color]'), bar = $('#topbar'); const tc = $<HTMLMetaElement>('meta[name=theme-color]'), bar = $('#topbar');
if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; } if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; }
const sel = $('#stheme'); if(sel) sel.value = theme.name; const sel = $<HTMLSelectElement>('#stheme'); if(sel) sel.value = theme.name;
const ms = $('#smode'); if(ms) ms.value = theme.mode; const ms = $<HTMLSelectElement>('#smode'); if(ms) ms.value = theme.mode;
const mf = $('#smodefield'); if(mf) mf.hidden = !both; const mf = $('#smodefield'); if(mf) mf.hidden = !both;
if(save) saveTheme(); if(save) saveTheme();
} }
@@ -62,6 +62,6 @@ systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTh
// becomes its cookie, once. // becomes its cookie, once.
let name: string | null = null, mode: string | null = null; let name: string | null = null, mode: string | null = null;
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{} try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name]; if(name&&OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
setTheme(name ?? undefined, mode ?? undefined, !!name); setTheme(name ?? undefined, mode ?? undefined, !!name);
})(); })();

153
web/src/types.ts Normal file
View File

@@ -0,0 +1,153 @@
// What ipx's API sends the page, as types for the type-check (#235), each named after the Rust
// struct it mirrors in crates/ipx/src. Types only: swc strips this file to nothing, so it is not
// in web/build.mjs's list; tsconfig.json's glob reads it. A field added there goes here too.
// An Option is `T | null`, as serde writes None.
/// A feed in your list, `/api/feeds` and the live `feed_row` (web.rs FeedRow).
interface Feed {
id: string;
url: string;
title: string | null;
image: string | null;
folder: string | null;
category: string | null;
feed_category: string | null;
keywords: string[];
blocked: string[];
deadair: boolean;
deadair_pause: number;
allow_explicit: boolean;
auto_download: boolean;
max_new_per_check: number | null;
/// The OPML subscription it came from.
group: string | null;
orphaned: boolean;
managed: boolean;
schedule: string | null;
schedule_mins: number | null;
every_mins: number;
last_checked: number | null;
next_check: number | null;
last_error: string | null;
failing: {reason: string, new_url: string | null} | null;
entries: number;
downloaded: number;
unread: number;
subscribers: number;
pinned: boolean;
private: boolean;
private_by_address: boolean;
}
/// An item, in a page of `/api/entries` (db.rs EntryRow).
interface Entry {
guid: string;
feed_id: string;
title: string | null;
link: string | null;
published: number | null;
description: string | null;
read: boolean;
flagged: boolean;
image: string | null;
duration: number | null;
episode: number | null;
season: number | null;
position: number;
enclosures: Enclosure[];
}
/// An item's file (db.rs EncRow).
interface Enclosure {
id: number;
/// Seconds DeadAir's copy saves, when you play it from one.
deadair?: number | null;
deadair_pending?: boolean;
feed_id: string;
guid: string;
url: string;
mime: string | null;
length: number | null;
path: string | null;
state: string;
last_error: string | null;
}
/// `/api/me`.
interface Me {
name: string;
admin: boolean;
sign_out: string;
blocked: string[];
deadair_saved: number;
theme: {name: string, mode: string} | null;
version: string;
}
/// A feed as the Directory lists it (web.rs PopularRow): never its address.
interface DirFeed {
id: string;
title: string | null;
image: string | null;
subscribers: number;
subscribed: boolean;
/// Its id in your list, when it is yours.
feed: string | null;
category: string | null;
subcategory: string | null;
podcast: boolean;
}
/// The page's own state (util.ts `S`).
interface State {
feeds: Feed[];
/// The feed or place open: a feed's id, or a view such as ':all'.
feed: string | null;
entries: Entry[];
total: number;
offset: number;
filter: string;
q: string;
/// The selected item's guid.
sel: string | null;
me: Me | null;
sort: {col: string, dir: 'asc' | 'desc'};
}
/// An account, `/api/users` (web.rs list_users).
interface Account {
id: number;
name: string;
admin: boolean;
/// Has a password; none signs in through the proxy.
password: boolean;
created: number | null;
last_login: number | null;
}
/// A line of the daemon's log, `/api/logs` (logbuf.rs LogLine).
interface LogLine {
seq: number;
ts: number;
level: string;
target: string;
msg: string;
}
/// `/api/directory-admin`: the servers sharing with the directory and the feeds it blocks
/// (directory lib.rs Instance, Blocked).
interface DirAdmin {
instances: {name: string, feeds: number, subscribers: number}[];
blocked: {id: string, url: string | null, title: string | null, at: number}[];
}
/// One of a listed feed's latest items, on its page in the Directory (web.rs ListedItem): never
/// its files.
interface ListedItem {
guid: string;
title: string | null;
link: string | null;
published: number | null;
duration: number | null;
description: string | null;
}

View File

@@ -1,15 +1,18 @@
'use strict'; 'use strict';
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the // `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
// smoke test, not the type checker, is what makes sure a selector exists. // smoke test, not the type checker, is what makes sure a selector exists.
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s); const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T => r.querySelector<T>(s)!;
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)]; const $$ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T[] => [...r.querySelectorAll<T>(s)];
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); /// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
const ESCAPED: Record<string,string> = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com // Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the // License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes // ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
// 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: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
const ICON={ const ICON: Record<string,string>={
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 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
@@ -58,15 +61,15 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a // One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
// dialog's confirm button carries the icon of what it does. Words go in the tooltip. // dialog's confirm button carries the icon of what it does. Words go in the tooltip.
// The page's own buttons name their icon; this draws it in, ahead of any label they carry. // The page's own buttons name their icon; this draws it in, ahead of any label they carry.
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]); for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon!]);
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line /// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
/// becomes how much shorter it is once the copy is ready (the dead_air event). /// becomes how much shorter it is once the copy is ready (the dead_air event).
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>'; const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
/// What DeadAir took off an episode, as the list says it. /// What DeadAir took off an episode, as the list says it.
function shorter(secs){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; } function shorter(secs: number){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
/// A total of time saved: "3 h 12 min", "12 min", "40 s". /// A total of time saved: "3 h 12 min", "12 min", "40 s".
function spanText(secs){ function spanText(secs: number){
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60); const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`; return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
} }
@@ -105,12 +108,12 @@ async function api(url: string, opts?: RequestInit): Promise<any>{
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a // A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
// whole 502 page (#219). // whole 502 page (#219).
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim()); if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
return r.status===204?null:r.json().catch(()=>null); return r.status===204?null:r.json().catch((): null=>null);
} }
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN // navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
// address it is undefined, so fall back to the old selection-based copy. // address it is undefined, so fall back to the old selection-based copy.
async function copyText(text,btn){ async function copyText(text: string,btn?: HTMLElement){
const flash=ok=>{ const flash=(ok: boolean)=>{
if(!btn) return; if(!btn) return;
// The button is an icon, so it is the markup that has to come back, not just its text. // The button is an icon, so it is the markup that has to come back, not just its text.
const was=btn.innerHTML; const was=btn.innerHTML;
@@ -144,14 +147,14 @@ async function copyText(text,btn){
/// ///
/// ponytail: a guess from the address. A private feed whose key has another name is shared /// 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. /// without asking; add its pattern here when one turns up.
async function share(title: string, url: string, btn?){ async function share(title: string, url: string, btn?: HTMLElement){
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url) 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 ' && !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; +'send it to could use it as you.\n\nShare it anyway?')) return;
if(navigator.share){ if(navigator.share){
// Cancelling the sheet rejects too, and is not a failure worth a word. // Cancelling the sheet rejects too, and is not a failure worth a word.
try{ await navigator.share({title, url}); } try{ await navigator.share({title, url}); }
catch(e){ if(e.name!=='AbortError') toast(e.message,true); } catch(e){ if((e as Error)?.name!=='AbortError') toast(why(e),true); }
return; return;
} }
await copyText(url,btn); await copyText(url,btn);
@@ -164,12 +167,12 @@ function toast(msg: string, bad?: boolean){
$('#toasts').appendChild(t); $('#toasts').appendChild(t);
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200); setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
} }
const clock = s => { const clock = (s: number) => {
s=Math.max(0,Math.floor(s||0)); s=Math.max(0,Math.floor(s||0));
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60; const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`; return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
}; };
const ago = t => { const ago = (t: number|null) => {
if(!t) return 'never'; if(!t) return 'never';
const d=(Date.now()/1000)-t; const d=(Date.now()/1000)-t;
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago'; if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
@@ -177,16 +180,16 @@ const ago = t => {
if(d<2592000) return Math.round(d/86400)+'d ago'; if(d<2592000) return Math.round(d/86400)+'d ago';
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
}; };
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):''; const dateOf = (t: number|null) => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB". // A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB' const mb = (n: number|null) => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB'; : n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?'; const initials = (s: string|null) => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`; const plural=(n: number,word: string)=>`${n} ${word}${n===1?'':'s'}`;
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match. // An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))']; const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; }; const tint=(name: string)=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
function tileHTML(name,cls){ function tileHTML(name: string,cls?: string){
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`; return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
} }
/// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and /// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and
@@ -195,25 +198,25 @@ function tileHTML(name,cls){
function artSrc(url: string){ function artSrc(url: string){
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url; return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
} }
function artHTML(url: string | null, name: string, cls?: string){ function artHTML(url: string | null | undefined, name: string, cls?: string){
return url return url
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">` ? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
: tileHTML(name,cls); : tileHTML(name,cls);
} }
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own. /// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
function folderArt(f,kids){ function folderArt(f: Feed,kids: Feed[]){
const art=kids.filter(c=>c.image).slice(0,4); const art=kids.filter(c=>c.image).slice(0,4);
if(art.length<4) return artHTML(f.image,f.title||f.id); if(art.length<4) return artHTML(f.image,f.title||f.id);
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole. // Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=> return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
`<img src="${esc(artSrc(c.image))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`; `<img src="${esc(artSrc(c.image!))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
} }
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away. /// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; } function nav(on: boolean){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
/* ---------------- state ---------------- */ /* ---------------- state ---------------- */
const S = { const S: State = {
feeds:[], feeds:[],
// Which feed (or place) and which tab were open last time, so a refresh lands back where // Which feed (or place) and which tab were open last time, so a refresh lands back where
// you were instead of jumping to the first feed alphabetically. // you were instead of jumping to the first feed alphabetically.
@@ -222,25 +225,25 @@ const S = {
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all', filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
q:'', sel:null, me:null, q:'', sel:null, me:null,
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time. // The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })() sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')??'null'); }catch{ return null; } })()
||{col:'published',dir:'desc'}, ||{col:'published',dir:'desc'},
}; };
const LIMIT = 50; const LIMIT = 50;
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']]; const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
const UNIT_MINS = {m:1, h:60, d:1440, w:10080}; const UNIT_MINS: Record<string,number> = {m:1, h:60, d:1440, w:10080};
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes". /// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
function splitEvery(m){ function splitEvery(m: number){
if(!m) return {n:1, u:'h'}; if(!m) return {n:1, u:'h'};
for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u}; for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u};
return {n:m, u:'m'}; return {n:m, u:'m'};
} }
function unitOptions(sel){ function unitOptions(sel: string){
return UNITS.map(([v,l]) => return UNITS.map(([v,l]) =>
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join(''); `<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
} }
function everyText(m){ function everyText(m: number){
if(!m) return '\u2014'; if(!m) return '\u2014';
const {n,u} = splitEvery(m); const {n,u} = splitEvery(m);
const name = {m:'min', h:'hour', d:'day', w:'week'}[u]; const name = {m:'min', h:'hour', d:'day', w:'week'}[u];