Popular on this server; changelog follows Keep a Changelog; 0.3.0
- Add feed lists what other accounts subscribe to, most subscribers
first, and subscribes you by id (GET /api/popular, POST
/api/popular/{id}). Rows never carry a URL. Feeds from an OPML and
anything that looks private (a login, credentials in the URL, a key
such as auth= or token=) are never listed, and the subscribe route
checks the id against the same list.
- CHANGELOG.md follows Keep a Changelog 1.1.0: 0.1.0 (2026-09-09, the
CLI), 0.2.0 (2026-09-10, the web UI), 0.3.0 (2026-09-11, accounts and
sharing). The long-form entries moved unchanged to docs/history.md.
- Cargo.toml is 0.3.0. CLAUDE.md says how to add an entry and cut a
release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
1440
CHANGELOG.md
1440
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
12
CLAUDE.md
12
CLAUDE.md
@@ -108,8 +108,16 @@ Comments explain **why**, not what. If a line looks odd, the comment says what w
|
|||||||
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
|
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
|
||||||
addressed to the person using it.
|
addressed to the person using it.
|
||||||
|
|
||||||
Every change gets an entry at the top of [CHANGELOG.md](CHANGELOG.md), dated, saying what landed and
|
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
|
||||||
what was wrong before. That record has been more useful than the git log more than once.
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
||||||
|
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
|
||||||
|
say, such as what was wrong before or what it cost to find out, write it up at the top of
|
||||||
|
[docs/history.md](docs/history.md), dated. That record has been more useful than the git log more
|
||||||
|
than once.
|
||||||
|
|
||||||
|
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
||||||
|
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
|
||||||
|
compare links at the bottom of the changelog.
|
||||||
|
|
||||||
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
|
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
|
||||||
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
|
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1634,7 +1634,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ change it with `echo -n '<password>' | ipx user passwd admin`.
|
|||||||
| [docs/users.md](docs/users.md) | Accounts, and what several people share |
|
| [docs/users.md](docs/users.md) | Accounts, and what several people share |
|
||||||
| [docs/sso.md](docs/sso.md) | Cloudflare Zero Trust or Authentik in front |
|
| [docs/sso.md](docs/sso.md) | Cloudflare Zero Trust or Authentik in front |
|
||||||
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, socket, HTTP API |
|
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, socket, HTTP API |
|
||||||
| [CHANGELOG.md](CHANGELOG.md) | What landed, and why |
|
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
|
||||||
|
| [docs/history.md](docs/history.md) | How it was built: the long form, with what was wrong and why |
|
||||||
| [CLAUDE.md](CLAUDE.md) | Notes for anyone (or anything) working on the code |
|
| [CLAUDE.md](CLAUDE.md) | Notes for anyone (or anything) working on the code |
|
||||||
|
|
||||||
## The web UI
|
## The web UI
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ else a `401`.
|
|||||||
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||||
| `POST /api/fetch` | |
|
| `POST /api/fetch` | |
|
||||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
||||||
|
| `GET /api/popular`, `POST /api/popular/{id}` | what others here subscribe to (id, title, art, count; never a URL, never a private feed); subscribe by id |
|
||||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
||||||
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||||
|
|||||||
1331
docs/history.md
Normal file
1331
docs/history.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,13 @@ list with their own read state. Unsubscribing removes it from their list alone;
|
|||||||
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
|
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
|
||||||
re-subscribing does not pull the back catalogue again.
|
re-subscribing does not pull the back catalogue again.
|
||||||
|
|
||||||
|
**Add feed** also lists what other people on this server subscribe to, most subscribers first, as
|
||||||
|
a place to start. It shows a title, artwork and a count, never a URL or who reads it. Feeds from an
|
||||||
|
OPML subscription are left out, since they come with the OPML. So is anything that looks private: a
|
||||||
|
login configured for the feed, credentials in its URL, or a key such as `auth=` or `token=` in the
|
||||||
|
query. Those are someone's paid subscriptions, and listing them would let anyone here read what they
|
||||||
|
pay for.
|
||||||
|
|
||||||
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
|
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
|
||||||
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
|
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
|
||||||
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or
|
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or
|
||||||
|
|||||||
108
src/web.rs
108
src/web.rs
@@ -74,6 +74,8 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/api/fetch", post(fetch_now))
|
.route("/api/fetch", post(fetch_now))
|
||||||
.route("/api/opml", get(export_opml).post(import_opml))
|
.route("/api/opml", get(export_opml).post(import_opml))
|
||||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||||
|
.route("/api/popular", get(get_popular))
|
||||||
|
.route("/api/popular/{id}", post(subscribe_popular))
|
||||||
.route("/api/users", get(list_users).post(add_user))
|
.route("/api/users", get(list_users).post(add_user))
|
||||||
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
|
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
|
||||||
.route("/api/logs", get(logs))
|
.route("/api/logs", get(logs))
|
||||||
@@ -530,6 +532,84 @@ async fn feeds(
|
|||||||
Ok(Json(out))
|
Ok(Json(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- popular on this server ----
|
||||||
|
|
||||||
|
/// A feed that carries a credential is someone's paid or private subscription. Listing it
|
||||||
|
/// would let anyone signed in subscribe to it and read what they pay for.
|
||||||
|
///
|
||||||
|
/// ponytail: a heuristic. A token hidden in the URL's path gets through; a per-feed
|
||||||
|
/// `unlisted` flag is the upgrade if that ever happens.
|
||||||
|
fn looks_private(feed: &crate::config::Feed) -> bool {
|
||||||
|
if feed.username.is_some() || feed.password.is_some() || feed.password_env.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Ok(u) = url::Url::parse(&feed.url) else { return true };
|
||||||
|
!u.username().is_empty()
|
||||||
|
|| u.password().is_some()
|
||||||
|
|| u.query_pairs().any(|(k, _)| {
|
||||||
|
let k = k.to_ascii_lowercase();
|
||||||
|
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"]
|
||||||
|
.iter()
|
||||||
|
.any(|w| k.contains(w))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only an id, a title, artwork and a count: never a URL, which is where a key would be.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PopularRow {
|
||||||
|
id: String,
|
||||||
|
title: Option<String>,
|
||||||
|
image: Option<String>,
|
||||||
|
subscribers: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What other people here subscribe to that you don't, most subscribers first. What the
|
||||||
|
/// Add feed screen offers, and all that `subscribe_popular` will subscribe you to.
|
||||||
|
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||||
|
let db = &state.ctx.db;
|
||||||
|
let mine: std::collections::HashSet<String> =
|
||||||
|
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
||||||
|
let counts = db.subscriber_counts()?;
|
||||||
|
let mut out = vec![];
|
||||||
|
for s in crate::subscriptions(&state.ctx)? {
|
||||||
|
let n = counts.get(&s.id).copied().unwrap_or(0);
|
||||||
|
// A feed from an OPML rides on the OPML: everyone subscribed to it counts every feed
|
||||||
|
// inside, which would bury everything anyone chose on purpose.
|
||||||
|
let from_opml = s.managed || s.cfg.group.is_some();
|
||||||
|
if n == 0 || from_opml || mine.contains(&s.id) || looks_private(&s.cfg) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sum = db.feed_summary(&s.id)?;
|
||||||
|
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n });
|
||||||
|
}
|
||||||
|
let name = |p: &PopularRow| p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase();
|
||||||
|
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).cmp(&name(b))));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_popular(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
|
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||||
|
let mut rows = popular(&state, user.id)?;
|
||||||
|
rows.truncate(20);
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribes by id, because the list never shows a URL. Checked against the same list, so
|
||||||
|
/// a guessed id cannot reach a private feed.
|
||||||
|
async fn subscribe_popular(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
if !popular(&state, user.id)?.iter().any(|p| p.id == id) {
|
||||||
|
return Err(ApiError::bad_request(format!("{id:?} is not on the popular list")));
|
||||||
|
}
|
||||||
|
state.ctx.db.subscribe(user.id, &id)?;
|
||||||
|
Ok(Json(serde_json::json!({ "id": id })))
|
||||||
|
}
|
||||||
|
|
||||||
/// Validates a replacement feed URL: present, parseable, and not already subscribed under
|
/// Validates a replacement feed URL: present, parseable, and not already subscribed under
|
||||||
/// a different id. Returns the trimmed URL.
|
/// a different id. Returns the trimmed URL.
|
||||||
fn check_url(
|
fn check_url(
|
||||||
@@ -594,6 +674,34 @@ impl IntoResponse for ApiError {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_feed_with_a_credential_is_never_popular() {
|
||||||
|
let f = |url: &str| crate::config::Feed {
|
||||||
|
url: url.into(),
|
||||||
|
folder: None,
|
||||||
|
group: None,
|
||||||
|
media_types: None,
|
||||||
|
schedule: None,
|
||||||
|
keywords: vec![],
|
||||||
|
allow_explicit: false,
|
||||||
|
auto_download: true,
|
||||||
|
max_new_per_check: None,
|
||||||
|
username: None,
|
||||||
|
password: None,
|
||||||
|
password_env: None,
|
||||||
|
};
|
||||||
|
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
||||||
|
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
||||||
|
// Patreon's shape: the key is a query parameter.
|
||||||
|
assert!(looks_private(&f("https://www.patreon.com/rss/x?auth=abc123&show=2073588")));
|
||||||
|
assert!(looks_private(&f("https://example.com/rss?api_key=abc")));
|
||||||
|
assert!(looks_private(&f("https://ray:hunter2@example.com/rss")));
|
||||||
|
assert!(looks_private(&f("not a url")), "unparseable is not safe to list");
|
||||||
|
let mut basic = f("https://example.com/rss");
|
||||||
|
basic.username = Some("ray".into());
|
||||||
|
assert!(looks_private(&basic), "a feed with a login configured");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_the_last_admin_is_protected() {
|
fn only_the_last_admin_is_protected() {
|
||||||
let u = |id, is_admin| crate::db::User { id, name: format!("u{id}"), pass_hash: None, is_admin };
|
let u = |id, is_admin| crate::db::User { id, name: format!("u{id}"), pass_hash: None, is_admin };
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ test.beforeEach(async ({ page }) => {
|
|||||||
test('the page loads and lists the configured feeds', async ({ page }) => {
|
test('the page loads and lists the configured feeds', async ({ page }) => {
|
||||||
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
||||||
// empty, with every handler below the error dead. Server-side checks all passed.
|
// empty, with every handler below the error dead. Server-side checks all passed.
|
||||||
// Three top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
// Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
||||||
await expect(page.locator('.feed')).toHaveCount(4, { timeout: 15_000 });
|
await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 });
|
||||||
await expect(page.getByText('Test Show')).toBeVisible();
|
await expect(page.getByText('Test Show')).toBeVisible();
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
@@ -544,3 +544,44 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
|
|||||||
expect(xml).toContain('http://127.0.0.1:8792/one.xml');
|
expect(xml).toContain('http://127.0.0.1:8792/one.xml');
|
||||||
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
|
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Add feed offers what other people here read, but never a private feed', async ({ browser }) => {
|
||||||
|
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', 'piper'], { input: 'piperpassword', env });
|
||||||
|
} catch (e) {
|
||||||
|
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
|
||||||
|
}
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const piper = await ctx.newPage();
|
||||||
|
await piper.goto('/login');
|
||||||
|
await piper.locator('#name').fill('piper');
|
||||||
|
await piper.locator('#pw').fill('piperpassword');
|
||||||
|
await piper.locator('button[type=submit]').click();
|
||||||
|
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
|
||||||
|
|
||||||
|
await piper.locator('#addFeed').click();
|
||||||
|
const offered = piper.locator('#popular .childrow');
|
||||||
|
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
// An OPML's own feeds ride on the OPML, and a key in a URL marks someone's paid feed.
|
||||||
|
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toHaveCount(0);
|
||||||
|
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
||||||
|
|
||||||
|
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
|
||||||
|
const listed = await (await piper.request.get('/api/popular')).text();
|
||||||
|
expect(listed).not.toContain('secret123');
|
||||||
|
expect(listed).not.toContain('.xml');
|
||||||
|
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
|
||||||
|
|
||||||
|
await offered.filter({ hasText: 'Test Show' }).locator('button', { hasText: 'Subscribe' }).click();
|
||||||
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
// Once it is yours, it is no longer offered.
|
||||||
|
expect(await (await piper.request.get('/api/popular')).text()).not.toContain('"test-show"');
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|||||||
5
tests/ui/fixtures/paid.xml
Normal file
5
tests/ui/fixtures/paid.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel><title>Paid Show</title><link>http://127.0.0.1:8792/</link>
|
||||||
|
<description>Subscribed with a key in its URL, so it must never be offered to anyone else.</description>
|
||||||
|
<item><title>Paid Ep</title><guid>paid-1</guid><description>x</description></item>
|
||||||
|
</channel></rss>
|
||||||
@@ -55,6 +55,11 @@ auto_download = true
|
|||||||
[feeds.test-subscriptions]
|
[feeds.test-subscriptions]
|
||||||
url = "http://127.0.0.1:8792/subs.opml"
|
url = "http://127.0.0.1:8792/subs.opml"
|
||||||
auto_download = false
|
auto_download = false
|
||||||
|
|
||||||
|
# A key in its URL, like a Patreon feed: someone's paid subscription, never offered to others.
|
||||||
|
[feeds.paid-show]
|
||||||
|
url = "http://127.0.0.1:8792/paid.xml?auth=secret123"
|
||||||
|
auto_download = false
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1142,9 +1142,12 @@ $('#addFeed').onclick=()=>{
|
|||||||
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
|
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
|
||||||
<div class="field"><label>Keywords (optional, comma separated)</label>
|
<div class="field"><label>Keywords (optional, comma separated)</label>
|
||||||
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
||||||
|
<div class="field"><label>Popular on this server</label>
|
||||||
|
<div class="childlist" id="popular"><p class="hint">Loading…</p></div></div>
|
||||||
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
|
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
|
||||||
<button class="btn primary" id="nsave">Add feed</button></div>`);
|
<button class="btn primary" id="nsave">Add feed</button></div>`);
|
||||||
$('#nurl').focus();
|
$('#nurl').focus();
|
||||||
|
showPopular();
|
||||||
$('#nsave').onclick=async()=>{
|
$('#nsave').onclick=async()=>{
|
||||||
const url=$('#nurl').value.trim(); if(!url) return;
|
const url=$('#nurl').value.trim(); if(!url) return;
|
||||||
$('#nsave').textContent='Adding…'; $('#nsave').disabled=true;
|
$('#nsave').textContent='Adding…'; $('#nsave').disabled=true;
|
||||||
@@ -1158,6 +1161,31 @@ $('#addFeed').onclick=()=>{
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// What other people here read, as a place to start. The rows carry an id, never a URL, so a
|
||||||
|
// key in someone's feed address never reaches this page.
|
||||||
|
async function showPopular(){
|
||||||
|
const box=$('#popular');
|
||||||
|
let rows=[];
|
||||||
|
try{ rows=await api('/api/popular')||[]; }catch{}
|
||||||
|
box.innerHTML=rows.length?'':'<p class="hint">Nothing yet. Feeds other people here subscribe to show up here.</p>';
|
||||||
|
for(const p of rows){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='childrow';
|
||||||
|
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
||||||
|
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
||||||
|
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
||||||
|
`<button class="btn" data-a="sub">Subscribe</button>`;
|
||||||
|
$('[data-a="sub"]',el).onclick=async()=>{
|
||||||
|
try{
|
||||||
|
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||||
|
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
|
||||||
|
await loadFeeds(true); selectFeed(p.id);
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
};
|
||||||
|
box.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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){
|
||||||
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
|
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user