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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
26
src/web.rs
26
src/web.rs
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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){
|
||||||
|
|||||||
Reference in New Issue
Block a user