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 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 15:00:50 +00:00
parent 7ecbe8293f
commit a5e64e520d
9 changed files with 87 additions and 36 deletions

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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'){

View File

@@ -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<string>();
/// 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<string, number>();
/// 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 <body>, 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<string, number>={};
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 <body>,
/// 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(){