A click on a feed, All Subscriptions or a filter fetched the list and only then drew it, so nothing answered the click until the server did. The page now keeps the first page of each list (the newest 40, by view, filter, sort and search), draws a kept one at once and refreshes it behind, drawing again only if it changed. It fetches ahead: All Subscriptions and the twenty feeds with the most unread once the page is idle, two at a time, and a feed or filter when the pointer or a finger reaches it. A feed_row or a read forgets that feed's lists and All Subscriptions'; the forgotten are fetched again behind. A list with nothing kept shows a spinner if its answer takes more than 120 ms. list_shown (#246) is reported when the list first shows, so a kept one reports its few ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
4.7 KiB
TypeScript
79 lines
4.7 KiB
TypeScript
/* ---------------- live events ---------------- */
|
|
let sse: EventSource;
|
|
/// The feed list and the open list, taken again from the server. The events keep both current,
|
|
/// and one missed is a count wrong until a reload: reading an item sends none to your other
|
|
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
|
|
/// the app is in the background, when everything is missed (#168).
|
|
const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
|
loadFeeds(true).catch(()=>{});
|
|
if(S.feed) loadEntries(false,true).catch(()=>{});
|
|
},300); }; })();
|
|
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
|
|
let dropped=false;
|
|
function connect(){
|
|
sse=new EventSource('/api/events');
|
|
// Back after a drop: whatever was sent meanwhile went to nobody.
|
|
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
|
const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
|
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
|
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(false,true).catch(()=>{}); });
|
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
|
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
|
|
// The lists a change made stale, fetched again behind, once a burst of rows has passed (#247).
|
|
const preloadSoon=soon(preloadLists,2000);
|
|
sse.onmessage=m=>{
|
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
|
if(ev.ev==='progress'){
|
|
const pct=ev.total?ev.done/ev.total*100:0;
|
|
// Only the row actually downloading. Without the enclosure id this used to paint
|
|
// every pending bar at once, so adding a feed looked like it was fetching the lot.
|
|
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
|
|
if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); }
|
|
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
|
|
}
|
|
else if(ev.ev==='download_done'){
|
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
|
// 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();
|
|
}
|
|
// A copy finished: the episode's "Cutting dead air…" becomes how much shorter it is.
|
|
else if(ev.ev==='deadair_done'){
|
|
if(!mine(ev.feed)) return;
|
|
if(ev.saved_s>0&&document.querySelector('.dapend')) toast(`DeadAir is done: ${shorter(ev.saved_s)}`);
|
|
refreshEntries();
|
|
if(S.sel) loadEntries(false,true).then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
|
|
}
|
|
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'){
|
|
setScanning(ev.feed,false);
|
|
if(!mine(ev.feed)) return;
|
|
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') setScanning(ev.feed,false);
|
|
// The server sends a feed's new row after anything changes it (counts, error, last check),
|
|
// only to those who subscribe; the page used to reload the whole list after each event.
|
|
else if(ev.ev==='feed_row'){ patchFeed(ev.row); forgetLists(ev.row.id); preloadSoon(); }
|
|
// Only a scan that checked something: the scheduler scans every minute, due or not, and
|
|
// 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(); 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); };
|
|
}
|
|
connect();
|
|
loadFeeds().then(preloadLists).catch(()=>{});
|