From c0f4b0bcb2a9a79cb332a3fccad3447082cedccc Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 11 Sep 2026 14:05:24 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn --- CHANGELOG.md | 10 ++++++++++ docs/architecture.md | 2 +- docs/users.md | 5 +++-- src/web.rs | 12 ++++++++---- tests/page-smoke.js | 6 +++++- tests/ui/app.spec.js | 15 +++++++++++---- web/index.html | 20 ++++++++++++++++---- 7 files changed, 54 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db3bd0..c88945f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ The long form, with what was wrong before and how it was found, is in ## [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 ### Added diff --git a/docs/architecture.md b/docs/architecture.md index 859f6c3..81c44ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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/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 | diff --git a/docs/users.md b/docs/users.md index 1df8a10..b8892d1 100644 --- a/docs/users.md +++ b/docs/users.md @@ -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. -**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 +**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. +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 diff --git a/src/web.rs b/src/web.rs index 5e91d15..c965bb9 100644 --- a/src/web.rs +++ b/src/web.rs @@ -561,10 +561,13 @@ struct PopularRow { title: Option, image: Option, 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 -/// Add feed screen offers, and all that `subscribe_popular` will subscribe you to. +/// 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. fn popular(state: &WebState, user_id: i64) -> Result> { let db = &state.ctx.db; let mine: std::collections::HashSet = @@ -576,11 +579,12 @@ fn popular(state: &WebState, user_id: i64) -> Result> { // 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) { + if n == 0 || from_opml || 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 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)))); diff --git a/tests/page-smoke.js b/tests/page-smoke.js index 482e6d3..7296b57 100644 --- a/tests/page-smoke.js +++ b/tests/page-smoke.js @@ -46,7 +46,10 @@ 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') + ? [{ 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 = () => {}; }, MediaMetadata: function () {}, @@ -83,6 +86,7 @@ const drive = [ ['prefsModal', () => ctx.prefsModal()], ['usersModal', () => ctx.usersModal()], ['opmlModal', () => ctx.opmlModal()], + ['showPopular', () => ctx.showPopular()], ['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. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 35f1e2b..cc3393f 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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'); }); -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 setup = require('./global-setup'); 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 expect(piper.locator('#feedlist')).toContainText('No feeds.'); - await piper.locator('#addFeed').click(); + await piper.locator('#popularFeeds').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. @@ -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((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 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(); }); diff --git a/web/index.html b/web/index.html index 145a688..12cb8ca 100644 --- a/web/index.html +++ b/web/index.html @@ -373,6 +373,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
+
@@ -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 -// key in someone's feed address never reaches this page. +// 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(){ const box=$('#popular'); let rows=[]; try{ rows=await api('/api/popular')||[]; }catch{} - box.innerHTML=rows.length?'':'

Nothing yet. Feeds other people here subscribe to show up here.

'; + box.innerHTML=rows.length?'':'

Nothing yet. Feeds people here subscribe to show up here.

'; for(const p of rows){ const el=document.createElement('div'); el.className='childrow'; el.innerHTML=artHTML(p.image,p.title||p.id)+ `
${esc(p.title||p.id)}`+ `${p.subscribers} subscriber${p.subscribers===1?'':'s'}
`+ - ``; + (p.subscribed?'Subscribed':''); + // 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()=>{ try{ await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'}); @@ -1186,6 +1189,15 @@ async function showPopular(){ } } +$('#popularFeeds').onclick=()=>{ + openModal(`

Popular on this server

+

What everyone here subscribes to, you included, most + subscribers first. Feeds inside an OPML subscription, and private feeds, are never listed.

+ +
`); + showPopular(); +}; + let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]')); function toggleGroup(id){ expanded.has(id) ? expanded.delete(id) : expanded.add(id);