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
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
- Popular shows the top 10, not 20.
- The popular list counts everyone, you included. Your own feeds stay on it, marked Subscribed,
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/fetch` | |
| `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/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 |

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
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
server subscribes to, you included, most subscribers first. Your own feeds are marked Subscribed.
**Popular**, at the top of the feed list and in the Add feed dialog, lists the ten feeds with the
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
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

View File

@@ -75,6 +75,7 @@ pub fn router(state: WebState) -> Router {
.route("/api/opml", get(export_opml).post(import_opml))
.route("/api/settings", get(get_settings).patch(patch_settings))
.route("/api/popular", get(get_popular))
.route("/api/directory", get(get_directory))
.route("/api/popular/{id}", post(subscribe_popular))
.route("/api/users", get(list_users).post(add_user))
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
@@ -565,9 +566,9 @@ struct PopularRow {
subscribed: bool,
}
/// What everyone here subscribes to, you included, most subscribers first. What the Popular
/// button and the Add feed screen show, and all that `subscribe_popular` will subscribe
/// you to.
/// Every feed that may be listed, with everyone counted, you included, most subscribers
/// first. Popular is the top of it, the directory is all of it, and it is 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> =
@@ -586,8 +587,7 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
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();
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(|| sort_name(a).cmp(&sort_name(b))));
Ok(out)
}
@@ -596,10 +596,24 @@ async fn get_popular(
user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?;
rows.truncate(20);
rows.truncate(10);
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
/// a guessed id cannot reach a private feed.
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 }
: String(url).includes('/api/users')
? [{ 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: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: []),
@@ -86,7 +86,8 @@ const drive = [
['prefsModal', () => ctx.prefsModal()],
['usersModal', () => ctx.usersModal()],
['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()],
// `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.

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((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 () =>
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
const before = await row();

View File

@@ -75,7 +75,7 @@ a{color:var(--accent)}
color:var(--dim);flex:none;
}
.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{
flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
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 class="sidetools">
<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>
</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>
<button class="btn primary" id="nsave">Add feed</button></div>`);
$('#nurl').focus();
showPopular();
listFeeds('/api/popular');
$('#nsave').onclick=async()=>{
const url=$('#nurl').value.trim(); if(!url) return;
$('#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
// URL, so a key in someone's feed address never reaches this page.
async function showPopular(){
async function listFeeds(url){
const box=$('#popular');
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>';
for(const p of rows){
const el=document.createElement('div');
@@ -1189,14 +1190,19 @@ 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>
// Popular and the directory are the same list: the top ten by subscribers, or all of it A to Z.
function listedModal(title,blurb,url){
openModal(`<h3>${title}</h3>
<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="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')||'[]'));
function toggleGroup(id){