Popular is a top 10; a Directory lists every listed feed A to Z

- GET /api/popular returns the ten most subscribed feeds. The new GET
  /api/directory returns every feed that may be listed, sorted by name,
  from the same list: everyone counted, you included, never a URL, never
  a private feed or a feed inside an OPML. POST /api/popular/{id} still
  subscribes to anything on it.
- A Directory button sits beside Popular; both open the same list
  screen. The sidebar toolbar wraps rather than squeezing four buttons.
- Tests: the directory is A to Z, Popular is its top ten, Paid Show is
  in neither, and subscribing works from the directory.

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:13:16 +00:00
parent c0f4b0bcb2
commit f3825cfc57
7 changed files with 62 additions and 22 deletions

View File

@@ -14,9 +14,12 @@ The long form, with what was wrong before and how it was found, is in
- A Popular button at the top of the feed list opens the popular list without going through - A Popular button at the top of the feed list opens the popular list without going through
Add feed. Add feed.
- A Directory button lists every feed anyone on this server subscribes to, A to Z
(`GET /api/directory`), with the same rules as Popular.
### Changed ### Changed
- Popular shows the top 10, not 20.
- The popular list counts everyone, you included. Your own feeds stay on it, marked Subscribed, - The popular list counts everyone, you included. Your own feeds stay on it, marked Subscribed,
and clicking one opens it. and clicking one opens it.

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 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/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with everyone counted (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.
**Popular**, at the top of the feed list and in the Add feed dialog, lists what everyone on this **Popular**, at the top of the feed list and in the Add feed dialog, lists the ten feeds with the
server subscribes to, you included, most subscribers first. Your own feeds are marked Subscribed. most subscribers on this server, you included. **Directory**, beside it, lists every one of them
A to Z. Your own feeds are marked Subscribed.
It shows a title, artwork and a count, never a URL or who reads it. Feeds from an 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

View File

@@ -75,6 +75,7 @@ pub fn router(state: WebState) -> Router {
.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", get(get_popular))
.route("/api/directory", get(get_directory))
.route("/api/popular/{id}", post(subscribe_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))
@@ -565,9 +566,9 @@ struct PopularRow {
subscribed: bool, subscribed: bool,
} }
/// What everyone here subscribes to, you included, most subscribers first. What the Popular /// Every feed that may be listed, with everyone counted, you included, most subscribers
/// button and the Add feed screen show, and all that `subscribe_popular` will subscribe /// first. Popular is the top of it, the directory is all of it, and it is all that
/// you to. /// `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> =
@@ -586,8 +587,7 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
let subscribed = mine.contains(&s.id); let subscribed = mine.contains(&s.id);
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n, subscribed }); 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(); out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).cmp(&name(b))));
Ok(out) Ok(out)
} }
@@ -596,10 +596,24 @@ async fn get_popular(
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> { ) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?; let mut rows = popular(&state, user.id)?;
rows.truncate(20); rows.truncate(10);
Ok(Json(rows)) Ok(Json(rows))
} }
/// Every feed that may be listed, A to Z.
async fn get_directory(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?;
rows.sort_by_key(sort_name);
Ok(Json(rows))
}
fn sort_name(p: &PopularRow) -> String {
p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase()
}
/// Subscribes by id, because the list never shows a URL. Checked against the same list, so /// Subscribes by id, because the list never shows a URL. Checked against the same list, so
/// a guessed id cannot reach a private feed. /// a guessed id cannot reach a private feed.
async fn subscribe_popular( async fn subscribe_popular(

View File

@@ -46,7 +46,7 @@ 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') : /\/api\/(popular|directory)/.test(String(url))
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true }, ? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }] { id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: []), : []),
@@ -86,7 +86,8 @@ const drive = [
['prefsModal', () => ctx.prefsModal()], ['prefsModal', () => ctx.prefsModal()],
['usersModal', () => ctx.usersModal()], ['usersModal', () => ctx.usersModal()],
['opmlModal', () => ctx.opmlModal()], ['opmlModal', () => ctx.opmlModal()],
['showPopular', () => ctx.showPopular()], ['listedModal (popular)', () => ctx.listedModal('Popular', 'Top ten.', '/api/popular')],
['listedModal (directory)', () => ctx.listedModal('Directory', 'A to Z.', '/api/directory')],
['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

@@ -579,6 +579,21 @@ test('Popular lists what everyone here reads, but never a private feed', async (
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);
// Popular is the top ten of the directory, and the directory is every listed feed, A to Z.
const dir = await (await piper.request.get('/api/directory')).json();
const top = await (await piper.request.get('/api/popular')).json();
const names = dir.map(p => (p.title || p.id).toLowerCase());
expect(names).toEqual([...names].sort());
expect(top.length).toBe(Math.min(10, dir.length));
expect(top.every(t => dir.some(d => d.id === t.id))).toBe(true);
expect(dir.map(p => p.id)).not.toContain('paid-show');
// Subscribe from the directory this time; the popular list shares the same rows.
await piper.keyboard.press('Escape');
await piper.locator('#directoryFeeds').click();
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
const row = async () => const row = async () =>
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show'); (await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
const before = await row(); const before = await row();

View File

@@ -75,7 +75,7 @@ a{color:var(--accent)}
color:var(--dim);flex:none; color:var(--dim);flex:none;
} }
.iconbtn:hover{background:var(--raise);color:var(--fg)} .iconbtn:hover{background:var(--raise);color:var(--fg)}
.sidetools{display:flex;gap:6px;padding:0 12px 10px} .sidetools{display:flex;flex-wrap:wrap;gap:6px;padding:0 12px 10px}
.sidetools button,.sidefoot button{ .sidetools button,.sidefoot button{
flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px; flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 8px;font-size:12.5px;color:var(--dim); padding:6px 8px;font-size:12.5px;color:var(--dim);
@@ -373,7 +373,8 @@ 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="popularFeeds" title="The ten most subscribed feeds on this server">Popular</button>
<button id="directoryFeeds" title="Every feed anyone on this server subscribes to">Directory</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>
@@ -1148,7 +1149,7 @@ $('#addFeed').onclick=()=>{
<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(); listFeeds('/api/popular');
$('#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;
@@ -1164,10 +1165,10 @@ $('#addFeed').onclick=()=>{
// What everyone here reads, you included, as a place to start. The rows carry an id, never a // What everyone here reads, you included, 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. // URL, so a key in someone's feed address never reaches this page.
async function showPopular(){ async function listFeeds(url){
const box=$('#popular'); const box=$('#popular');
let rows=[]; let rows=[];
try{ rows=await api('/api/popular')||[]; }catch{} try{ rows=await api(url)||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing yet. Feeds 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');
@@ -1189,14 +1190,19 @@ async function showPopular(){
} }
} }
$('#popularFeeds').onclick=()=>{ // Popular and the directory are the same list: the top ten by subscribers, or all of it A to Z.
openModal(`<h3>Popular on this server</h3> function listedModal(title,blurb,url){
<p class="hint" style="margin:-6px 0 12px">What everyone here subscribes to, you included, most openModal(`<h3>${title}</h3>
subscribers first. Feeds inside an OPML subscription, and private feeds, are never listed.</p> <p class="hint" style="margin:-6px 0 12px">${blurb} Everyone counts, you included. 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="childlist" id="popular"><p class="hint">Loading…</p></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button></div>`); <div class="cardacts"><button class="btn" onclick="closeModal()">Close</button></div>`);
showPopular(); listFeeds(url);
}; }
$('#popularFeeds').onclick=()=>listedModal('Popular on this server',
'The ten feeds with the most subscribers here.','/api/popular');
$('#directoryFeeds').onclick=()=>listedModal('Directory',
'Every feed anyone on this server subscribes to, A to Z.','/api/directory');
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){