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:
@@ -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).
|
- 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 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).
|
- 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
|
## [1.1.0] - 2026-10-13
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,15 @@ pub enum Event {
|
|||||||
DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
|
DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
|
||||||
Reaped { path: String, bytes: u64 },
|
Reaped { path: String, bytes: u64 },
|
||||||
/// Terminal: a client that asked for work stops reading here.
|
/// 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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
forced: bool,
|
||||||
|
},
|
||||||
ReapDone { files: usize, bytes: u64 },
|
ReapDone { files: usize, bytes: u64 },
|
||||||
Status { feeds: usize, pending: i64, downloaded: i64 },
|
Status { feeds: usize, pending: i64, downloaded: i64 },
|
||||||
Error { msg: String },
|
Error { msg: String },
|
||||||
@@ -82,7 +90,7 @@ impl Event {
|
|||||||
Event::FeedStart { feed } => format!("{feed}: checking"),
|
Event::FeedStart { feed } => format!("{feed}: checking"),
|
||||||
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
||||||
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
|
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]
|
#[test]
|
||||||
fn only_completion_events_end_a_client_session() {
|
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::ReapDone { files: 0, bytes: 0 }.is_terminal());
|
||||||
assert!(!Event::FeedDone {
|
assert!(!Event::FeedDone {
|
||||||
feed: "a".into(),
|
feed: "a".into(),
|
||||||
|
|||||||
@@ -1361,7 +1361,7 @@ async fn fetch(ctx: &Arc<Ctx>, 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.
|
// What people subscribe to may have changed, and new feeds have titles now: share it.
|
||||||
ctx.directory_wake.notify_one();
|
ctx.directory_wake.notify_one();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2321,7 +2321,7 @@ async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Terminal, so a UI waiting on this request stops here.
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
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' });
|
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
||||||
await expect(row).toBeVisible();
|
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).toHaveClass(/\bscanning\b/);
|
||||||
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
||||||
await page.evaluate(() => setScanning('test-show', false));
|
await page.evaluate(() => setScanning('test-show', false));
|
||||||
await expect(row).not.toHaveClass(/\bscanning\b/);
|
await expect(row).not.toHaveClass(/\bscanning\b/);
|
||||||
await expect(row.locator('.badge')).toBeVisible();
|
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).
|
// Checking every feed says so on the rows, not in a toast (issue #37).
|
||||||
await page.locator('#scanAll').click();
|
expect(await page.evaluate(() => { scanAll(); return document.body.classList.contains('scan-any'); }),
|
||||||
await page.waitForTimeout(1500);
|
'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);
|
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();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
|
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
|
||||||
const btn = '.fhead [data-a=scan] .i';
|
const btn = '.fhead [data-a=scan] .i';
|
||||||
await expect(page.locator(btn)).toBeVisible();
|
await expect(page.locator(btn)).toBeVisible();
|
||||||
expect(await spin(btn)).toBe('none');
|
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(btn)).toBe('ipxspin');
|
||||||
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
expect(await spin('#scanAll .i')).toBe('none');
|
||||||
// Another feed being checked turns the toolbar's, not this page's.
|
// Feeds being checked one by one no longer start or stop it (#231).
|
||||||
await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); });
|
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');
|
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');
|
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');
|
expect(await spin('#scanAll .i')).toBe('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
## [1.0.1] - 2026-10-13
|
||||||
|
|
||||||
|
|||||||
@@ -729,7 +729,11 @@ function opmlModal(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No toast: the spinners on the rows being checked say it (issue #37).
|
// 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;
|
$('#scanAll').onclick=scanAll;
|
||||||
$('#prefs').onclick=prefsModal;
|
$('#prefs').onclick=prefsModal;
|
||||||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ function connect(){
|
|||||||
// every open page reloaded the whole list each time (#103).
|
// 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
|
// 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.
|
// 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); };
|
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,11 @@ function renderGroup(f,kids){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function feedAction(a,f){
|
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==='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==='rm') removeFeed(f);
|
||||||
if(a==='pin'){
|
if(a==='pin'){
|
||||||
|
|||||||
@@ -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.'},
|
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},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
};
|
};
|
||||||
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
/// Feeds being checked right now, from the event stream, and since when: their rows, and the row
|
||||||
/// holding one, carry a spinner.
|
/// of a folder holding one, carry a spinner.
|
||||||
const scanning=new Set<string>();
|
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){
|
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();
|
paintScanning();
|
||||||
}
|
}
|
||||||
function paintScanning(){
|
function paintScanning(){
|
||||||
|
const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)>=SPIN_AFTER;
|
||||||
for(const row of $$('#feedlist .feed')){
|
for(const row of $$('#feedlist .feed')){
|
||||||
const id=row.dataset.id;
|
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);
|
row.classList.toggle('scanning',on);
|
||||||
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
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 button turns on its own page only, wherever the person has gone since pressing it.
|
||||||
// a feed's page is redrawn as its items come in and would take a class on the button with it.
|
document.body.classList.toggle('scan-this',spinFeed!==null&&spinFeed===S.feed);
|
||||||
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)));
|
|
||||||
}
|
}
|
||||||
/// Starts a refresh button turning at once, and stops it only after a moment with nothing being
|
/// The refresh buttons turn from the press until that scan's scan_done (#78, #233). On <body>,
|
||||||
/// checked: a scan takes its feeds one at a time, and in the gap between two the class came off,
|
/// because a feed's page is redrawn as its items come in and would take a class on the button
|
||||||
/// the icon snapped back upright and started again, many times a second.
|
/// with it. Following the feeds being checked instead, the icon snapped back upright in the gap
|
||||||
const spinStop: Record<string, number>={};
|
/// between one feed and the next, many times a second (#231).
|
||||||
function spin(cls: string, on: boolean){
|
let spinFeed: string|null=null;
|
||||||
clearTimeout(spinStop[cls]);
|
function spinStart(feed: string|null){
|
||||||
if(on) document.body.classList.add(cls);
|
if(feed===null) document.body.classList.add('scan-any');
|
||||||
else spinStop[cls]=setTimeout(()=>document.body.classList.remove(cls),600);
|
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(){
|
function renderFeeds(){
|
||||||
|
|||||||
Reference in New Issue
Block a user