All Subscriptions said 1 unread when the server had none. The badge is the page's own sum of its feed list, loaded once and patched only by events the server sends when a scan or a download changes a feed. Reading an item, marking everything read or unsubscribing sends none, so another device's page kept its old counts, and an iPhone stops the event stream while the app is in the background, missing everything meanwhile; the reconnect did not catch up. The page now takes the feed list and the open list again when it comes back into view and when the stream reconnects after a drop. A browser test reads an item behind the page's back and checks the badge follows once the page is shown again; it fails without this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
67 lines
3.7 KiB
TypeScript
67 lines
3.7 KiB
TypeScript
/* ---------------- live events ---------------- */
|
|
let sse;
|
|
/// 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; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
|
loadFeeds(true).catch(()=>{});
|
|
if(S.feed) loadEntries().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,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
|
// 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'){
|
|
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();
|
|
}
|
|
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);
|
|
// 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(); if(ev.feeds) refreshEntries(); }
|
|
};
|
|
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
|
}
|
|
connect();
|
|
loadFeeds();
|