From 905dfa0b0210969b9eda1a31d7c9c343e59b3ef4 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 19 Sep 2026 01:17:40 +0000 Subject: [PATCH] A spinner on the feed being checked, not toasts; check only your own feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan's events reach everyone, so every browser showed ": N new" and "Scanning…" toasts, and refreshed, for everyone's feeds. Now a feed's row, and its folder's, carries a spinner between feed_start and its done, skip or error; the list refreshes only for the reader's own feeds; the scan toasts are gone, and "Downloaded" is said only for a file on screen. "Check every feed" from the web UI sent a scan of every feed on the server. Command::Fetch takes an optional `feeds` list -- those feeds and the feeds inside any OPML among them -- and the web fills it with the asker's subscriptions. The schedule and the CLI send none, meaning every feed. Closes #37. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 +++++++ src/ipc.rs | 9 +++++++-- src/main.rs | 16 ++++++++++------ src/web.rs | 14 ++++++++++++-- tests/ui/app.spec.js | 40 ++++++++++++++++++++++++++++++++++++++++ web/app.css | 6 ++++++ web/src/dialogs.ts | 3 ++- web/src/events.ts | 24 +++++++++++++----------- web/src/feedpage.ts | 2 +- web/src/feeds.ts | 17 +++++++++++++++++ web/src/gestures.ts | 1 - 11 files changed, 115 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b047cf6..71b31ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- A feed being checked shows a small spinner at the end of its row (and its folder's), instead of + toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A + "Downloaded" toast is only for a file on your screen. +- "Check every feed" checks every feed you subscribe to, not every feed on the server. + ## [0.8.0] - 2026-09-18 ### Added diff --git a/src/ipc.rs b/src/ipc.rs index 231cdd1..5ce36ec 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -88,6 +88,11 @@ pub enum Command { feed: Option, #[serde(default)] force: bool, + /// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed" + /// from the web UI is every feed of the person asking, not of everyone (issue #37). + /// Empty is every feed, as the schedule and the CLI mean it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + feeds: Vec, }, Reap { #[serde(default)] @@ -301,10 +306,10 @@ mod tests { #[test] fn commands_parse_from_the_wire_form() { let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap(); - assert!(matches!(got, Command::Fetch { feed: None, force: false })); + assert!(matches!(got, Command::Fetch { feed: None, force: false, .. })); let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap(); - assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp")); + assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp")); let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap(); assert!(matches!(got, Command::Reap { dry_run: true })); diff --git a/src/main.rs b/src/main.rs index cdb9196..4a0286d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -197,7 +197,7 @@ async fn main() -> Result<()> { // A daemon owns the state; don't have two processes downloading the same thing. let wire_cmd = match &cli.command { Command::Fetch { feed, force } => { - Some(Cmd::Fetch { feed: feed.clone(), force: *force }) + Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }) } Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }), Command::Status => Some(Cmd::Status), @@ -354,10 +354,10 @@ async fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { async fn run(ctx: &Arc, cmd: Cmd) -> Result<()> { match cmd { - Cmd::Fetch { feed, force } => { + Cmd::Fetch { feed, force, feeds } => { // Make room before pulling more down, as the original did per download. reap(ctx, false, false).await?; - fetch(ctx, feed.as_deref(), force).await + fetch(ctx, feed.as_deref(), force, &feeds).await } Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await, Cmd::Download { enclosure } => download_one(ctx, enclosure).await, @@ -510,7 +510,7 @@ async fn daemon( } _ = ticker.tick() => { // Per-feed schedule and TTL decide what actually gets polled. - let job = run(&ctx, Cmd::Fetch { feed: None, force: false }); + let job = run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] }); if !until_stopped(&ctx, &rx_stop, job).await { break; } @@ -892,7 +892,8 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> { Ok(()) } -async fn fetch(ctx: &Arc, only: Option<&str>, force: bool) -> Result<()> { +/// `scope`, when not empty, narrows the scan to those feeds and the feeds inside any of them. +async fn fetch(ctx: &Arc, only: Option<&str>, force: bool, scope: &[String]) -> Result<()> { let cfg = ctx.cfg(); let subs = subscriptions(ctx).await?; if let Some(id) = only @@ -903,7 +904,10 @@ async fn fetch(ctx: &Arc, only: Option<&str>, force: bool) -> Result<()> { let mut scanned = 0; let mut fresh: Vec = vec![]; - for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) { + let in_scope = |s: &Sub| { + scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g)) + }; + for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) { let (id, feed_cfg) = (&sub.id, &sub.cfg); let state = ctx.db.http_state(id).await?; diff --git a/src/web.rs b/src/web.rs index 3381921..16da0b0 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1239,7 +1239,7 @@ async fn add_feed( /// succeeded either way, so a daemon not taking commands is only logged. async fn scan_soon(state: &WebState, feed: Option) { let force = feed.is_some(); - if state.cmds.send(Command::Fetch { feed, force }).await.is_err() { + if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() { tracing::warn!("could not queue a scan: the daemon is not accepting commands"); } } @@ -1527,11 +1527,21 @@ struct FetchBody { async fn fetch_now( State(state): State, + user: crate::db::User, Json(body): Json, ) -> Result { + // "Check every feed" is every feed this person reads, not everyone's (issue #37). + let feeds = if body.feed.is_some() { + vec![] + } else { + state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect() + }; + if body.feed.is_none() && feeds.is_empty() { + return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all + } state .cmds - .send(Command::Fetch { feed: body.feed, force: body.force }) + .send(Command::Fetch { feed: body.feed, force: body.force, feeds }) .await .map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?; Ok(StatusCode::ACCEPTED) diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index a0d3ceb..fefc72f 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1299,3 +1299,43 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list' await expect(page.locator('.feed.pinned')).toHaveCount(0); await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1); }); + +test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => { + const row = page.locator('#feedlist .feed', { hasText: 'Test Show' }); + await expect(row).toBeVisible(); + await page.evaluate(() => setScanning('test-show', true)); + await expect(row).toHaveClass(/\bscanning\b/); + await page.evaluate(() => setScanning('test-show', false)); + await expect(row).not.toHaveClass(/\bscanning\b/); + // Checking every feed says so on the rows, not in a toast (issue #37). + await page.locator('#scanAll').click(); + await page.waitForTimeout(1500); + await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0); +}); + +test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => { + // piper, made in an earlier test, subscribes to Test Show alone. + const ctx = await browser.newContext(); + const piper = await ctx.newPage(); + await piper.goto('/login'); + await piper.locator('#name').fill('piper'); + await piper.locator('#pw').fill('piperpassword'); + await piper.locator('button[type=submit]').click(); + await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 }); + const mine = await piper.evaluate(() => S.feeds.map(f => f.id)); + // Listen as the page does, then ask. + await piper.evaluate(() => { + window.started = []; window.done = false; + const es = new EventSource('/api/events'); + es.onmessage = m => { const ev = JSON.parse(m.data); + if (ev.ev === 'feed_start') window.started.push(ev.feed); + if (ev.ev === 'scan_done') window.done = true; }; + }); + await piper.waitForTimeout(500); + await piper.locator('#scanAll').click(); + await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true); + const started = await piper.evaluate(() => window.started); + expect(started.length, 'something was checked').toBeGreaterThan(0); + for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id); + await ctx.close(); +}); diff --git a/web/app.css b/web/app.css index 95afa72..184e97a 100644 --- a/web/app.css +++ b/web/app.css @@ -368,6 +368,12 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} } .chev:hover{color:var(--fg)} .chev.bad,.chev.bad:hover{color:var(--bad)} +/* A feed being checked: a small spinner at the row's end, after its count (issue #37). */ +.feed.scanning::after{ + content:"";flex:none;width:12px;height:12px;border-radius:50%; + border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite; +} +@keyframes ipxspin{to{transform:rotate(360deg)}} /* A feed's error mark, in the triangle's place: the same column as every folder's triangle, a child's included, which is why it moves left by the child's indent. */ .ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)} diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 023f802..2e84df0 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -371,7 +371,8 @@ function opmlModal(){ }; } -async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } +// No toast: the spinners on the rows being checked say it (issue #37). +async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } $('#scanAll').onclick=scanAll; $('#prefs').onclick=prefsModal; // Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while diff --git a/web/src/events.ts b/web/src/events.ts index 581abf1..1366161 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -5,12 +5,8 @@ function connect(){ const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; const refreshFeeds=soon(()=>loadFeeds(true)); const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); - let fresh={}; - const tellNew=soon(()=>{ - const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0); - if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`); - fresh={}; - },900); + // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh. + const mine=id=>S.feeds.some(f=>f.id===id); sse.onmessage=m=>{ let ev; try{ ev=JSON.parse(m.data) }catch{ return } if(ev.ev==='progress'){ @@ -23,22 +19,28 @@ function connect(){ } else if(ev.ev==='download_done'){ const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); - if(bar) bar.classList.remove('live'); - toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds(); + // Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of + // everyone's feeds used to announce themselves to everyone. + if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); } + refreshEntries(); refreshFeeds(); } else if(ev.ev==='download_error'){ const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); if(bar) bar.classList.remove('live'); toast('Download failed: '+ev.msg,true); refreshEntries(); } + // A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37). + else if(ev.ev==='feed_start') setScanning(ev.feed,true); + else if(ev.ev==='feed_skip') setScanning(ev.feed,false); else if(ev.ev==='feed_done'){ - if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); } + setScanning(ev.feed,false); + if(!mine(ev.feed)) return; refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries(); } // No toast: a scan of every feed raised one per failure, to everyone. The feed list's // red ! marks the feed instead, and its page says why. - else if(ev.ev==='feed_error') refreshFeeds(); - else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); } + else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); } + else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); } }; sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); }; } diff --git a/web/src/feedpage.ts b/web/src/feedpage.ts index 21fd4bd..2a1621f 100644 --- a/web/src/feedpage.ts +++ b/web/src/feedpage.ts @@ -156,7 +156,7 @@ function renderGroup(f,kids){ } async function feedAction(a,f){ - if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } + if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); } if(a==='rm') removeFeed(f); if(a==='pin'){ diff --git a/web/src/feeds.ts b/web/src/feeds.ts index 6cf9f19..9bcb6b7 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -25,6 +25,22 @@ const VIEWS={ blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'}, ':all':{title:'All Subscriptions',icon:ICON.all}, }; +/// Feeds being checked right now, from the event stream: their rows, and the row of a folder +/// holding one, carry a spinner. +const scanning=new Set(); +function setScanning(id: string, on: boolean){ + if(on) scanning.add(id); else scanning.delete(id); + paintScanning(); +} +function paintScanning(){ + for(const row of $$('#feedlist .feed')){ + const id=row.dataset.id; + const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id)); + row.classList.toggle('scanning',on); + if(on) row.title='Checking for new items…'; else row.removeAttribute('title'); + } +} + function renderFeeds(){ const q=$('#feedFilter').value.trim().toLowerCase(); const list=$('#feedlist'); const top=list.scrollTop; @@ -104,6 +120,7 @@ function renderFeeds(){ if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; list.appendChild(el); } + paintScanning(); done(); } function selectFeed(id){ diff --git a/web/src/gestures.ts b/web/src/gestures.ts index 5a21bd0..bece9ec 100644 --- a/web/src/gestures.ts +++ b/web/src/gestures.ts @@ -34,7 +34,6 @@ function pullShow(dy: number){ function refreshFeed(){ const f = S.feeds.find(x => x.id === S.feed); if(!f && S.feed !== ':all') return; - toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…'); // New items arrive by the event stream when the scan finishes, as they do for a button press. api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})}) .catch(e => toast(e.message, true));