With no network the home-screen app showed nothing: no service worker, and the lists the page had fetched, full text included, lived only in its memory. A service worker (web/sw/sw.ts, TypeScript checked against a worker's globals by tsconfig.sw.json, built by swc to /sw.js) goes to the network first for the page and every API read and keeps the answers, so online nothing is an old copy and #388's version check works as before; offline the kept copy answers. Scripts named by hash come from what is kept. Reads, flags and places in an episode that cannot reach the server wait in an outbox, sent in order when the connection is back (and new ones queue behind them meanwhile, so none lands out of turn); a kept list served offline shows the reads waiting there. Pictures: a worker cannot keep another site's pictures sensibly, since browsers count each opaque answer as megabytes. ipx now remembers the picture addresses in item text it sends (bounded, in memory) and /api/art fetches those as it does artwork, through the guarded client; the worker keeps them, for unread items, under a 200 MB budget, and serves them offline in place of the publisher's. Online, pictures still come from their publisher. Sign-out, or another account signing in on the device, clears what was kept. The browser suite blocks the worker except in the offline tests, since it answers before page.route can. The fixture JPEGs are placeholders a browser cannot draw; the offline test uses a real PNG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
7.6 KiB
TypeScript
135 lines
7.6 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(); 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<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');
|
|
// 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(()=>{});
|