From a5e64e520d248085f199a39d0768f26bab0e699d Mon Sep 17 00:00:00 2001 From: rays Date: Wed, 14 Oct 2026 15:00:50 +0000 Subject: [PATCH] Refresh buttons turn from the press to the end of their scan; quick checks keep the count (#232, #233) The buttons followed the feeds being checked, so they stopped between one feed and the next; 7ecbe82 held them for 600 ms over each gap. Now pressing one starts it, and scan_done stops it only when it is the end of the scan asked for: scan_done carries `only` and `forced`, so a scheduled scan finishing first does not stop a button whose scan is queued behind it. An error event, a job that failed without a scan_done, stops them all. A row's spinner stood in for its unread count from the moment its feed was checked; most checks take milliseconds, so each count blinked as the scan went by. It now waits 300 ms. Co-Authored-By: Claude Opus 5.5 --- crates/ipx/CHANGELOG.md | 1 + crates/ipx/src/ipc.rs | 14 ++++++++++--- crates/ipx/src/main.rs | 4 ++-- tests/ui/app.spec.js | 41 +++++++++++++++++++++++++++++--------- web/CHANGELOG.md | 3 ++- web/src/dialogs.ts | 6 +++++- web/src/events.ts | 4 +++- web/src/feedpage.ts | 6 +++++- web/src/feeds.ts | 44 ++++++++++++++++++++++++----------------- 9 files changed, 87 insertions(+), 36 deletions(-) diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index f628783..c06762a 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228). - A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230). - A scan's trace names the feed each fetch is for (#229). +- `scan_done` on the event stream says which feed or folder the scan was limited to and whether someone asked for it (`only`, `forced`), so a page can tell the end of its own scan from a scheduled one's (#233). ## [1.1.0] - 2026-10-13 diff --git a/crates/ipx/src/ipc.rs b/crates/ipx/src/ipc.rs index 26c216d..2e8c152 100644 --- a/crates/ipx/src/ipc.rs +++ b/crates/ipx/src/ipc.rs @@ -36,7 +36,15 @@ pub enum Event { DeadairDone { feed: String, enclosure: i64, saved_s: i64 }, Reaped { path: String, bytes: u64 }, /// Terminal: a client that asked for work stops reading here. - ScanDone { feeds: usize }, + ScanDone { + feeds: usize, + /// The feed or folder the scan was limited to, and whether someone asked for it: a page + /// stops the button they pressed at the end of their scan, not at a scheduled one's (#233). + #[serde(default)] + only: Option, + #[serde(default)] + forced: bool, + }, ReapDone { files: usize, bytes: u64 }, Status { feeds: usize, pending: i64, downloaded: i64 }, Error { msg: String }, @@ -82,7 +90,7 @@ impl Event { Event::FeedStart { feed } => format!("{feed}: checking"), Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"), Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"), - Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"), + Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"), }) } } @@ -434,7 +442,7 @@ mod tests { #[test] fn only_completion_events_end_a_client_session() { - assert!(Event::ScanDone { feeds: 1 }.is_terminal()); + assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal()); assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal()); assert!(!Event::FeedDone { feed: "a".into(), diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index 6d97562..9be989f 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -1361,7 +1361,7 @@ async fn fetch(ctx: &Arc, only: Option<&str>, force: bool, scope: &[String] } } } - ctx.out.emit(Event::ScanDone { feeds: scanned }); + ctx.out.emit(Event::ScanDone { feeds: scanned, only: only.map(str::to_owned), forced: force }); // What people subscribe to may have changed, and new feeds have titles now: share it. ctx.directory_wake.notify_one(); Ok(()) @@ -2321,7 +2321,7 @@ async fn download_one(ctx: &Arc, id: i64) -> Result<()> { } } // Terminal, so a UI waiting on this request stops here. - ctx.out.emit(Event::ScanDone { feeds: 1 }); + ctx.out.emit(Event::ScanDone { feeds: 1, only: None, forced: false }); Ok(()) } diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index ef5097b..65bee55 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1697,15 +1697,30 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list' 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)); + // A check of a few milliseconds leaves the count where it is, or every count blinked as a scan + // went by; the spinner comes once the check has taken a moment (#232). + const atOnce = await page.evaluate(() => { + setScanning('test-show', true); + return document.querySelector('#feedlist .feed[data-id="test-show"]').classList.contains('scanning'); + }); + expect(atOnce, 'no spinner the moment a check starts').toBe(false); await expect(row).toHaveClass(/\bscanning\b/); await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden(); await page.evaluate(() => setScanning('test-show', false)); await expect(row).not.toHaveClass(/\bscanning\b/); await expect(row.locator('.badge')).toBeVisible(); + // The button turns from the press to the end of the scan it asked for (#233): a scheduled + // scan's end, or the end of a scan of one other feed, leaves it turning. + const spins = () => document.body.classList.contains('scan-any'); + expect(await page.evaluate(spins => { spinStart(null); spinEnd({ only: null, forced: false }); + const a = document.body.classList.contains('scan-any'); spinEnd({ only: 'test-show', forced: true }); + return a && document.body.classList.contains('scan-any'); })).toBe(true); + await page.evaluate(() => spinEnd({ only: null, forced: true })); + expect(await page.evaluate(spins)).toBe(false); // Checking every feed says so on the rows, not in a toast (issue #37). - await page.locator('#scanAll').click(); - await page.waitForTimeout(1500); + expect(await page.evaluate(() => { scanAll(); return document.body.classList.contains('scan-any'); }), + 'turning the moment it is pressed').toBe(true); + await expect(page.locator('body')).not.toHaveClass(/\bscan-any\b/, { timeout: 15_000 }); await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0); }); @@ -1766,20 +1781,28 @@ test('Share hands the feed, the item and the file to the share sheet', async ({ ]); }); -test('a refresh button turns while what it checks is being checked', async ({ page }) => { +test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => { await page.locator('.feed', { hasText: 'Test Show' }).click(); const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName); const btn = '.fhead [data-a=scan] .i'; await expect(page.locator(btn)).toBeVisible(); expect(await spin(btn)).toBe('none'); - await page.evaluate(() => setScanning('test-show', true)); + // Pressed: this feed's button turns, the toolbar's does not (#78, #233). + await page.evaluate(() => spinStart('test-show')); expect(await spin(btn)).toBe('ipxspin'); - expect(await spin('#scanAll .i')).toBe('ipxspin'); - // Another feed being checked turns the toolbar's, not this page's. - await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); }); + expect(await spin('#scanAll .i')).toBe('none'); + // Feeds being checked one by one no longer start or stop it (#231). + await page.evaluate(() => { setScanning('test-show', true); setScanning('test-show', false); }); + expect(await spin(btn)).toBe('ipxspin'); + // Another feed's scan ending, or a scheduled one's, leaves it turning; its own stops it. + await page.evaluate(() => { spinEnd({ only: 'multi-show', forced: true }); spinEnd({ only: null, forced: false }); }); + expect(await spin(btn)).toBe('ipxspin'); + await page.evaluate(() => spinEnd({ only: 'test-show', forced: true })); expect(await spin(btn)).toBe('none'); + // The toolbar's turns for a scan of every feed, until that scan ends. + await page.evaluate(() => spinStart(null)); expect(await spin('#scanAll .i')).toBe('ipxspin'); - await page.evaluate(() => setScanning('multi-show', false)); + await page.evaluate(() => spinEnd({ only: null, forced: true })); expect(await spin('#scanAll .i')).toBe('none'); }); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 15716d9..95ff462 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- The check-every-feed button turns smoothly through a scan instead of jerking back upright between one feed and the next (#231). +- The refresh buttons turn from the moment they are pressed until the scan they asked for has finished, smoothly, instead of jerking back upright between one feed and the next (#231, #233). +- A feed's unread count stays put while a scan checks it quickly; the spinner takes its place only when a check takes a moment (#232). ## [1.0.1] - 2026-10-13 diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index d1dc3f6..305cd39 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -729,7 +729,11 @@ function opmlModal(){ } // 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})}); } +async function scanAll(){ + spinStart(null); + try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } + catch(e){ spinEnd(); throw e; } +} $('#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 a99f734..575fb4b 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -66,7 +66,9 @@ function connect(){ // every open page reloaded the whole list each time (#103). // Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the // page reloads; reload the list here when a scan synced an OPML if that ever matters. - else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); } + else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); spinEnd(ev); if(ev.feeds) refreshEntries(); } + // The worker's job failed, so the scan someone is waiting on will send no scan_done. + else if(ev.ev==='error') spinEnd(); }; sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); }; } diff --git a/web/src/feedpage.ts b/web/src/feedpage.ts index 14ef733..6990a86 100644 --- a/web/src/feedpage.ts +++ b/web/src/feedpage.ts @@ -159,7 +159,11 @@ function renderGroup(f,kids){ } async function feedAction(a,f){ - if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } + if(a==='scan'){ + spinStart(f.id); + try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } + catch(e){ spinEnd(); throw e; } + } 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 9be3deb..ea53cd3 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -34,34 +34,42 @@ 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(); +/// Feeds being checked right now, from the event stream, and since when: their rows, and the row +/// of a folder holding one, carry a spinner. +const scanning=new Map(); +/// A row's spinner takes the place of its unread count, so it waits this long: most feeds are +/// checked in a few milliseconds, and each count blinked out and back as its feed went by. +const SPIN_AFTER=300; function setScanning(id: string, on: boolean){ - if(on) scanning.add(id); else scanning.delete(id); + if(on){ scanning.set(id,Date.now()); setTimeout(paintScanning,SPIN_AFTER); } else scanning.delete(id); paintScanning(); } function paintScanning(){ + const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)>=SPIN_AFTER; 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)); + const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id)); row.classList.toggle('scanning',on); if(on) row.title='Checking for new items…'; else row.removeAttribute('title'); } - // The refresh buttons turn while what they check is being checked (#78). On , because - // a feed's page is redrawn as its items come in and would take a class on the button with it. - const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id)); - spin('scan-this',busy(S.feed)); - spin('scan-any',S.feeds.some(f=>scanning.has(f.id))); + // A feed's button turns on its own page only, wherever the person has gone since pressing it. + document.body.classList.toggle('scan-this',spinFeed!==null&&spinFeed===S.feed); } -/// Starts a refresh button turning at once, and stops it only after a moment with nothing being -/// checked: a scan takes its feeds one at a time, and in the gap between two the class came off, -/// the icon snapped back upright and started again, many times a second. -const spinStop: Record={}; -function spin(cls: string, on: boolean){ - clearTimeout(spinStop[cls]); - if(on) document.body.classList.add(cls); - else spinStop[cls]=setTimeout(()=>document.body.classList.remove(cls),600); +/// The refresh buttons turn from the press until that scan's scan_done (#78, #233). On , +/// because a feed's page is redrawn as its items come in and would take a class on the button +/// with it. Following the feeds being checked instead, the icon snapped back upright in the gap +/// between one feed and the next, many times a second (#231). +let spinFeed: string|null=null; +function spinStart(feed: string|null){ + if(feed===null) document.body.classList.add('scan-any'); + else { spinFeed=feed; paintScanning(); } +} +/// `only` and `forced` from scan_done: a scheduled scan, or one limited to another feed, leaves +/// a pressed button turning, since the scan it asked for is still queued behind it. Nothing +/// (an error ended the job) stops them all. +function spinEnd(done?: {only: string|null, forced: boolean}){ + if(!done||done.forced&&done.only==null) document.body.classList.remove('scan-any'); + if(!done||done.forced&&done.only===spinFeed){ spinFeed=null; paintScanning(); } } function renderFeeds(){