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);