/* ---------------- 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(); checkVersion(); flushOutbox(); } }; 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 } // Someone else's download is theirs: its file's name and its failure stay off this page (#294). if((ev.ev==='progress'||ev.ev==='download_error')&&!mine(ev.feed)) 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(`.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()); } // On iPX now, in what this page holds too, so the open file's box says so at once rather // than after the list comes again; and, asked for on this device, its copy here follows (#457). for(const e of S.entries) for(const x of e.enclosures) if(x.id===ev.enclosure){ x.path=ev.path; x.state='done'; } if(wanted.has(ev.enclosure)) followWanted({id: ev.enclosure, path: ev.path}); else syncKeepButtons(); 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'); unwant(ev.enclosure); // Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304). const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure)); toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries(); } // Jev picked items out for someone's Signals: theirs are fetched again, and the one open. else if(ev.ev==='signal_hits'){ if(!S.signals.length) return; refreshSignals(); if(signalOf(S.feed)) 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'||S.feeds.some(f=>f.id===ev.feed&&f.group===S.feed)) 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); }; } /// A deploy of ipx-web drops the stream and the page kept running the script it loaded, in an /// open tab or the home-screen app, until reloaded by hand (#388). Back after a drop, the page /// asks for itself again: a different app.js named there is a new version. const runningScript=document.querySelector('script[src^="/app.js"]')?.getAttribute('src'); async function checkVersion(){ if(!runningScript) return; let page: string; // Mid-deploy, / can be a 502 or the sign-in page: no script named, nothing to compare. try{ const r=await fetch('/',{cache:'no-store'}); if(!r.ok) return; page=await r.text(); }catch{ return; } const live=page.match(/\/app\.js\?v=[0-9a-f]+/)?.[0]; if(!live||live===runningScript) return; if(reloadWouldLose()) offerReload(); else reloadKeepingPlace(); } /// Something a reload throws away: an episode playing, a dialog open, a search or a field typed in. function reloadWouldLose(){ const typing=document.activeElement instanceof HTMLTextAreaElement || (document.activeElement instanceof HTMLInputElement && document.activeElement.type!=='range' && document.activeElement.type!=='checkbox' && document.activeElement.value!==''); return !audio.paused || !!$('#modal')?.classList.contains('on') || !!S.q || typing; } /// The feed and tab come back from localStorage; the item open is carried across in the session, /// whole: read, it is off the Unread list the server sends after the reload. function reloadKeepingPlace(){ try{ const e=cur(); if(e) sessionStorage.setItem('ipx.reopen',JSON.stringify(e)); }catch{} location.reload(); } /// The item open before a reload for a new version, once, for showList to open again. function takeReopen(): Entry|null{ try{ const j=sessionStorage.getItem('ipx.reopen'); sessionStorage.removeItem('ipx.reopen'); return j ? JSON.parse(j) as Entry : null; }catch{ return null; } } let offered=false; function offerReload(){ if(offered) return; offered=true; const t=document.createElement('div'), b=document.createElement('button'); t.className='toast'; t.setAttribute('role','status'); t.textContent='A new version of iPX is ready. '; b.type='button'; b.className='btn'; b.textContent='Reload'; b.onclick=reloadKeepingPlace; t.appendChild(b); $('#toasts')?.appendChild(t); } connect(); loadFeeds().then(preloadLists).catch(()=>{});