Popular button in the sidebar; the popular list counts everyone

- A Popular button beside + Feed opens the popular list directly; the
  Add feed dialog keeps it too.
- The list counts every subscriber, you included. Your own feeds stay on
  it, marked Subscribed, and clicking one opens it. Private feeds and
  feeds inside an OPML are still never listed, for anyone.
- GET /api/popular rows carry `subscribed`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
2026-09-11 14:05:24 +00:00
parent 8b8b48302b
commit c0f4b0bcb2
7 changed files with 54 additions and 16 deletions

View File

@@ -10,6 +10,16 @@ The long form, with what was wrong before and how it was found, is in
## [Unreleased] ## [Unreleased]
### Added
- A Popular button at the top of the feed list opens the popular list without going through
Add feed.
### Changed
- The popular list counts everyone, you included. Your own feeds stay on it, marked Subscribed,
and clicking one opens it.
## [0.3.0] - 2026-09-11 ## [0.3.0] - 2026-09-11
### Added ### Added

View File

@@ -110,7 +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/popular`, `POST /api/popular/{id}` | what everyone here subscribes to, you included (id, title, art, count, whether it is yours; 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 |

View File

@@ -70,8 +70,9 @@ 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 **Popular**, at the top of the feed list and in the Add feed dialog, lists what everyone on this
a place to start. It shows a title, artwork and a count, never a URL or who reads it. Feeds from an server subscribes to, you included, most subscribers first. Your own feeds are marked Subscribed.
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 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 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 query. Those are someone's paid subscriptions, and listing them would let anyone here read what they

View File

@@ -561,10 +561,13 @@ struct PopularRow {
title: Option<String>, title: Option<String>,
image: Option<String>, image: Option<String>,
subscribers: i64, subscribers: i64,
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
subscribed: bool,
} }
/// What other people here subscribe to that you don't, most subscribers first. What the /// What everyone here subscribes to, you included, most subscribers first. What the Popular
/// Add feed screen offers, and all that `subscribe_popular` will subscribe you to. /// button and the Add feed screen show, and all that `subscribe_popular` will subscribe
/// you to.
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> { fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
let db = &state.ctx.db; let db = &state.ctx.db;
let mine: std::collections::HashSet<String> = let mine: std::collections::HashSet<String> =
@@ -576,11 +579,12 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
// A feed from an OPML rides on the OPML: everyone subscribed to it counts every feed // 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. // inside, which would bury everything anyone chose on purpose.
let from_opml = s.managed || s.cfg.group.is_some(); let from_opml = s.managed || s.cfg.group.is_some();
if n == 0 || from_opml || mine.contains(&s.id) || looks_private(&s.cfg) { if n == 0 || from_opml || looks_private(&s.cfg) {
continue; continue;
} }
let sum = db.feed_summary(&s.id)?; let sum = db.feed_summary(&s.id)?;
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n }); let subscribed = mine.contains(&s.id);
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n, subscribed });
} }
let name = |p: &PopularRow| p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase(); 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)))); out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).cmp(&name(b))));

View File

@@ -46,7 +46,10 @@ const ctx = {
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 } ? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
: String(url).includes('/api/users') : String(url).includes('/api/users')
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }] ? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
: []), : String(url).includes('/api/popular')
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: []),
}), }),
EventSource: function () { this.close = () => {}; }, EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {}, MediaMetadata: function () {},
@@ -83,6 +86,7 @@ const drive = [
['prefsModal', () => ctx.prefsModal()], ['prefsModal', () => ctx.prefsModal()],
['usersModal', () => ctx.usersModal()], ['usersModal', () => ctx.usersModal()],
['opmlModal', () => ctx.opmlModal()], ['opmlModal', () => ctx.opmlModal()],
['showPopular', () => ctx.showPopular()],
['logsModal', () => ctx.logsModal()], ['logsModal', () => ctx.logsModal()],
// `const S` is not reachable from here: top-level const/let do not become properties // `const S` is not reachable from here: top-level const/let do not become properties
// of a vm context the way var and function declarations do. // of a vm context the way var and function declarations do.

View File

@@ -545,7 +545,7 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
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 }) => { test('Popular lists what everyone here reads, but never a private feed', async ({ browser }) => {
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const setup = require('./global-setup'); const setup = require('./global-setup');
const env = { const env = {
@@ -566,7 +566,7 @@ test('Add feed offers what other people here read, but never a private feed', as
await piper.locator('button[type=submit]').click(); await piper.locator('button[type=submit]').click();
await expect(piper.locator('#feedlist')).toContainText('No feeds.'); await expect(piper.locator('#feedlist')).toContainText('No feeds.');
await piper.locator('#addFeed').click(); await piper.locator('#popularFeeds').click();
const offered = piper.locator('#popular .childrow'); const offered = piper.locator('#popular .childrow');
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 }); 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. // An OPML's own feeds ride on the OPML, and a key in a URL marks someone's paid feed.
@@ -579,9 +579,16 @@ test('Add feed offers what other people here read, but never a private feed', as
expect(listed).not.toContain('.xml'); expect(listed).not.toContain('.xml');
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400); expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
const row = async () =>
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
const before = await row();
expect(before.subscribed).toBe(false);
await offered.filter({ hasText: 'Test Show' }).locator('button', { hasText: 'Subscribe' }).click(); await offered.filter({ hasText: 'Test Show' }).locator('button', { hasText: 'Subscribe' }).click();
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 }); 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"'); // Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
await piper.locator('#popularFeeds').click();
await expect(offered.filter({ hasText: 'Test Show' })).toContainText('Subscribed');
await ctx.close(); await ctx.close();
}); });

View File

@@ -373,6 +373,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
</div> </div>
<div class="sidetools"> <div class="sidetools">
<button id="addFeed">+ Feed</button> <button id="addFeed">+ Feed</button>
<button id="popularFeeds" title="What everyone on this server subscribes to">Popular</button>
<button id="scanAll">Scan all</button> <button id="scanAll">Scan all</button>
</div> </div>
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div> <div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
@@ -1161,20 +1162,22 @@ $('#addFeed').onclick=()=>{
}; };
}; };
// What other people here read, as a place to start. The rows carry an id, never a URL, so a // What everyone here reads, you included, as a place to start. The rows carry an id, never a
// key in someone's feed address never reaches this page. // URL, so a key in someone's feed address never reaches this page.
async function showPopular(){ async function showPopular(){
const box=$('#popular'); const box=$('#popular');
let rows=[]; let rows=[];
try{ rows=await api('/api/popular')||[]; }catch{} 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>'; box.innerHTML=rows.length?'':'<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
for(const p of rows){ for(const p of rows){
const el=document.createElement('div'); const el=document.createElement('div');
el.className='childrow'; el.className='childrow';
el.innerHTML=artHTML(p.image,p.title||p.id)+ el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+ `<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+ `<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
`<button class="btn" data-a="sub">Subscribe</button>`; (p.subscribed?'<span class="tag">Subscribed</span>':'<button class="btn" data-a="sub">Subscribe</button>');
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
$('[data-a="sub"]',el).onclick=async()=>{ $('[data-a="sub"]',el).onclick=async()=>{
try{ try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'}); await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
@@ -1186,6 +1189,15 @@ async function showPopular(){
} }
} }
$('#popularFeeds').onclick=()=>{
openModal(`<h3>Popular on this server</h3>
<p class="hint" style="margin:-6px 0 12px">What everyone here subscribes to, you included, most
subscribers first. Feeds inside an OPML subscription, and private feeds, are never listed.</p>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button></div>`);
showPopular();
};
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);