diff --git a/CHANGELOG.md b/CHANGELOG.md index c88945f..a2fd1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 81c44ed..deb3738 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 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 | diff --git a/docs/users.md b/docs/users.md index b8892d1..5eaf452 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. -**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 diff --git a/src/web.rs b/src/web.rs index c965bb9..0ac0471 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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> { let db = &state.ctx.db; let mine: std::collections::HashSet = @@ -586,8 +587,7 @@ fn popular(state: &WebState, user_id: i64) -> Result> { 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>, 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, + user: crate::db::User, +) -> Result>, 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( diff --git a/tests/page-smoke.js b/tests/page-smoke.js index 7296b57..f9b4695 100644 --- a/tests/page-smoke.js +++ b/tests/page-smoke.js @@ -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. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index cc3393f..b9c2801 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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(); diff --git a/web/index.html b/web/index.html index 12cb8ca..128db58 100644 --- a/web/index.html +++ b/web/index.html @@ -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
- + +
@@ -1148,7 +1149,7 @@ $('#addFeed').onclick=()=>{
`); $('#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?'':'

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

'; for(const p of rows){ const el=document.createElement('div'); @@ -1189,14 +1190,19 @@ 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.

+// 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(`

${title}

+

${blurb} Everyone counts, you included. Feeds inside + an OPML subscription, and private feeds, are never listed.

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